This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,450 @@
<audio id="audio" title="01 | 使用了并发工具类库,线程安全就高枕无忧了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/ed/5fdcdac948fa08ba18ebb2a93dc1b9ed.mp3"></audio>
你好,我是朱晔。作为课程的第一讲,我今天要和你聊聊使用并发工具类库相关的话题。
在代码审核讨论的时候我们有时会听到有关线程安全和并发工具的一些片面的观点和结论比如“把HashMap改为ConcurrentHashMap就可以解决并发问题了呀”“要不我们试试无锁的CopyOnWriteArrayList吧性能更好”。事实上这些说法都不太准确。
的确,为了方便开发者进行多线程编程,现代编程语言会提供各种并发工具类。但如果我们没有充分了解它们的使用场景、解决的问题,以及最佳实践的话,盲目使用就可能会导致一些坑,小则损失性能,大则无法确保多线程情况下业务逻辑的正确性。
我需要先说明下,这里的并发工具类是指用来解决多线程环境下并发问题的工具类库。一般而言并发工具包括同步器和容器两大类,业务代码中使用并发容器的情况会多一些,我今天分享的例子也会侧重并发容器。
接下来,我们就看看在使用并发工具时,最常遇到哪些坑,以及如何解决、避免这些坑吧。
## 没有意识到线程重用导致用户信息错乱的Bug
之前有业务同学和我反馈在生产上遇到一个诡异的问题有时获取到的用户信息是别人的。查看代码后我发现他使用了ThreadLocal来缓存获取到的用户信息。
我们知道ThreadLocal适用于变量在线程间隔离而在方法或类间共享的场景。如果用户信息的获取比较昂贵比如从数据库查询用户信息那么在ThreadLocal中缓存数据是比较合适的做法。但这么做为什么会出现用户信息错乱的Bug呢
我们看一个具体的案例吧。
使用Spring Boot创建一个Web应用程序使用ThreadLocal存放一个Integer的值来暂且代表需要在线程中保存的用户信息这个值初始是null。在业务逻辑中我先从ThreadLocal获取一次值然后把外部传入的参数设置到ThreadLocal中来模拟从当前上下文获取到用户信息的逻辑随后再获取一次值最后输出两次获得的值和线程名称。
```
private static final ThreadLocal&lt;Integer&gt; currentUser = ThreadLocal.withInitial(() -&gt; null);
@GetMapping(&quot;wrong&quot;)
public Map wrong(@RequestParam(&quot;userId&quot;) Integer userId) {
//设置用户信息之前先查询一次ThreadLocal中的用户信息
String before = Thread.currentThread().getName() + &quot;:&quot; + currentUser.get();
//设置用户信息到ThreadLocal
currentUser.set(userId);
//设置用户信息之后再查询一次ThreadLocal中的用户信息
String after = Thread.currentThread().getName() + &quot;:&quot; + currentUser.get();
//汇总输出两次查询结果
Map result = new HashMap();
result.put(&quot;before&quot;, before);
result.put(&quot;after&quot;, after);
return result;
}
```
按理说在设置用户信息之前第一次获取的值始终应该是null但我们要意识到程序运行在Tomcat中执行程序的线程是Tomcat的工作线程而Tomcat的工作线程是基于线程池的。
**顾名思义线程池会重用固定的几个线程一旦线程重用那么很可能首次从ThreadLocal获取的值是之前其他用户的请求遗留的值。这时ThreadLocal中的用户信息就是其他用户的信息。**
为了更快地重现这个问题我在配置文件中设置一下Tomcat的参数把工作线程池最大线程数设置为1这样始终是同一个线程在处理请求
```
server.tomcat.max-threads=1
```
运行程序后先让用户1来请求接口可以看到第一和第二次获取到用户ID分别是null和1符合预期<br>
<img src="https://static001.geekbang.org/resource/image/4b/30/4b8f38415d03423132c7a3608ebe2430.png" alt="">
随后用户2来请求接口这次就出现了Bug第一和第二次获取到用户ID分别是1和2显然第一次获取到了用户1的信息原因就是Tomcat的线程池重用了线程。从图中可以看到两次请求的线程都是同一个线程http-nio-8080-exec-1。
<img src="https://static001.geekbang.org/resource/image/a9/db/a9ccd42716d807687b3acff9a0baf2db.png" alt="">
这个例子告诉我们,在写业务代码时,首先要理解代码会跑在什么线程上:
- 我们可能会抱怨学多线程没用因为代码里没有开启使用多线程。但其实可能只是我们没有意识到在Tomcat这种Web服务器下跑的业务代码本来就运行在一个多线程环境否则接口也不可能支持这么高的并发**并不能认为没有显式开启多线程就不会有线程安全问题**。
- 因为线程的创建比较昂贵所以Web服务器往往会使用线程池来处理请求这就意味着线程会被重用。这时**使用类似ThreadLocal工具来存放一些数据时需要特别注意在代码运行完后显式地去清空设置的数据**。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。
理解了这个知识点后我们修正这段代码的方案是在代码的finally代码块中显式清除ThreadLocal中的数据。这样一来新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了。修正后的代码如下
```
@GetMapping(&quot;right&quot;)
public Map right(@RequestParam(&quot;userId&quot;) Integer userId) {
String before = Thread.currentThread().getName() + &quot;:&quot; + currentUser.get();
currentUser.set(userId);
try {
String after = Thread.currentThread().getName() + &quot;:&quot; + currentUser.get();
Map result = new HashMap();
result.put(&quot;before&quot;, before);
result.put(&quot;after&quot;, after);
return result;
} finally {
//在finally代码块中删除ThreadLocal中的数据确保数据不串
currentUser.remove();
}
}
```
重新运行程序可以验证再也不会出现第一次查询用户信息查询到之前用户请求的Bug
<img src="https://static001.geekbang.org/resource/image/0d/cc/0dfe40fca441b58d491fc799d120a7cc.png" alt="">
ThreadLocal是利用独占资源的方式来解决线程安全问题那如果我们确实需要有资源在线程之间共享应该怎么办呢这时我们可能就需要用到线程安全的容器了。
## 使用了线程安全的并发工具,并不代表解决了所有线程安全问题
JDK 1.5后推出的ConcurrentHashMap是一个高性能的线程安全的哈希表容器。“线程安全”这四个字特别容易让人误解因为**ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的。**
我在相当多的业务代码中看到过这个误区比如下面这个场景。有一个含900个元素的Map现在再补充100个元素进去这个补充操作由10个线程并发进行。开发人员误以为使用了ConcurrentHashMap就不会有线程安全问题于是不加思索地写出了下面的代码在每一个线程的代码逻辑中先通过size方法拿到当前元素数量计算ConcurrentHashMap目前还需要补充多少元素并在日志中输出了这个值然后通过putAll方法把缺少的元素添加进去。
为方便观察问题我们输出了这个Map一开始和最后的元素个数。
```
//线程个数
private static int THREAD_COUNT = 10;
//总元素数量
private static int ITEM_COUNT = 1000;
//帮助方法用来获得一个指定元素数量模拟数据的ConcurrentHashMap
private ConcurrentHashMap&lt;String, Long&gt; getData(int count) {
return LongStream.rangeClosed(1, count)
.boxed()
.collect(Collectors.toConcurrentMap(i -&gt; UUID.randomUUID().toString(), Function.identity(),
(o1, o2) -&gt; o1, ConcurrentHashMap::new));
}
@GetMapping(&quot;wrong&quot;)
public String wrong() throws InterruptedException {
ConcurrentHashMap&lt;String, Long&gt; concurrentHashMap = getData(ITEM_COUNT - 100);
//初始900个元素
log.info(&quot;init size:{}&quot;, concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
//使用线程池并发处理逻辑
forkJoinPool.execute(() -&gt; IntStream.rangeClosed(1, 10).parallel().forEach(i -&gt; {
//查询还需要补充多少个元素
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info(&quot;gap size:{}&quot;, gap);
//补充元素
concurrentHashMap.putAll(getData(gap));
}));
//等待所有任务完成
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//最后元素个数会是1000吗
log.info(&quot;finish size:{}&quot;, concurrentHashMap.size());
return &quot;OK&quot;;
}
```
访问接口后程序输出的日志内容如下:
<img src="https://static001.geekbang.org/resource/image/2e/70/2eaf5cd1b910b2678aca15fee6144070.png" alt="">
从日志中可以看到:
- 初始大小900符合预期还需要填充100个元素。
- worker1线程查询到当前需要填充的元素为36竟然还不是100的倍数。
- worker13线程查询到需要填充的元素数是负的显然已经过度填充了。
- 最后HashMap的总项目数是1536显然不符合填充满1000的预期。
针对这个场景我们可以举一个形象的例子。ConcurrentHashMap就像是一个大篮子现在这个篮子里有900个桔子我们期望把这个篮子装满1000个桔子也就是再装100个桔子。有10个工人来干这件事儿大家先后到岗后会计算还需要补多少个桔子进去最后把桔子装入篮子。
ConcurrentHashMap这个篮子本身可以确保多个工人在装东西进去时不会相互影响干扰但无法确保工人A看到还需要装100个桔子但是还未装的时候工人B就看不到篮子中的桔子数量。更值得注意的是你往这个篮子装100个桔子的操作不是原子性的在别人看来可能会有一个瞬间篮子里有964个桔子还需要补36个桔子。
回到ConcurrentHashMap我们需要注意**ConcurrentHashMap对外提供的方法或能力的限制**
- 使用了ConcurrentHashMap不代表对它的多个操作之间的状态是一致的是没有其他线程在操作它的如果需要确保需要手动加锁。
- 诸如size、isEmpty和containsValue等聚合方法在并发情况下可能会反映ConcurrentHashMap的中间状态。因此在并发情况下这些方法的返回值只能用作参考而不能用于流程控制。显然利用size方法计算差异值是一个流程控制。
- 诸如putAll这样的聚合方法也不能确保原子性在putAll的过程中去获取数据可能会获取到部分数据。
代码的修改方案很简单,整段逻辑加锁即可:
```
@GetMapping(&quot;right&quot;)
public String right() throws InterruptedException {
ConcurrentHashMap&lt;String, Long&gt; concurrentHashMap = getData(ITEM_COUNT - 100);
log.info(&quot;init size:{}&quot;, concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -&gt; IntStream.rangeClosed(1, 10).parallel().forEach(i -&gt; {
//下面的这段复合逻辑需要锁一下这个ConcurrentHashMap
synchronized (concurrentHashMap) {
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info(&quot;gap size:{}&quot;, gap);
concurrentHashMap.putAll(getData(gap));
}
}));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
log.info(&quot;finish size:{}&quot;, concurrentHashMap.size());
return &quot;OK&quot;;
}
```
重新调用接口,程序的日志输出结果符合预期:
<img src="https://static001.geekbang.org/resource/image/11/b8/1151b5b87f27073725060b76c56d95b8.png" alt="">
可以看到只有一个线程查询到了需要补100个元素其他9个线程查询到不需要补元素最后Map大小为1000。
到了这里你可能又要问了使用ConcurrentHashMap全程加锁还不如使用普通的HashMap呢。
其实不完全是这样。
ConcurrentHashMap提供了一些原子性的简单复合逻辑方法用好这些方法就可以发挥其威力。这就引申出代码中常见的另一个问题在使用一些类库提供的高级工具类时开发人员可能还是按照旧的方式去使用这些新类因为没有使用其特性所以无法发挥其威力。
## 没有充分了解并发工具的特性,从而无法发挥其威力
我们来看一个使用Map来统计Key出现次数的场景吧这个逻辑在业务代码中非常常见。
- 使用ConcurrentHashMap来统计Key的范围是10。
- 使用最多10个并发循环操作1000万次每次操作累加随机的Key。
- 如果Key不存在的话首次设置值为1。
代码如下:
```
//循环次数
private static int LOOP_COUNT = 10000000;
//线程数量
private static int THREAD_COUNT = 10;
//元素数量
private static int ITEM_COUNT = 10;
private Map&lt;String, Long&gt; normaluse() throws InterruptedException {
ConcurrentHashMap&lt;String, Long&gt; freqs = new ConcurrentHashMap&lt;&gt;(ITEM_COUNT);
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -&gt; IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -&gt; {
//获得一个随机的Key
String key = &quot;item&quot; + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
synchronized (freqs) {
if (freqs.containsKey(key)) {
//Key存在则+1
freqs.put(key, freqs.get(key) + 1);
} else {
//Key不存在则初始化为1
freqs.put(key, 1L);
}
}
}
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
return freqs;
}
```
我们吸取之前的教训直接通过锁的方式锁住Map然后做判断、读取现在的累计值、加1、保存累加后值的逻辑。这段代码在功能上没有问题但无法充分发挥ConcurrentHashMap的威力改进后的代码如下
```
private Map&lt;String, Long&gt; gooduse() throws InterruptedException {
ConcurrentHashMap&lt;String, LongAdder&gt; freqs = new ConcurrentHashMap&lt;&gt;(ITEM_COUNT);
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -&gt; IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -&gt; {
String key = &quot;item&quot; + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
//利用computeIfAbsent()方法来实例化LongAdder然后利用LongAdder来进行线程安全计数
freqs.computeIfAbsent(key, k -&gt; new LongAdder()).increment();
}
));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//因为我们的Value是LongAdder而不是Long所以需要做一次转换才能返回
return freqs.entrySet().stream()
.collect(Collectors.toMap(
e -&gt; e.getKey(),
e -&gt; e.getValue().longValue())
);
}
```
在这段改进后的代码中,我们巧妙利用了下面两点:
- 使用ConcurrentHashMap的原子性方法computeIfAbsent来做复合逻辑操作判断Key是否存在Value如果不存在则把Lambda表达式运行后的结果放入Map作为Value也就是新创建一个LongAdder对象最后返回Value。
- 由于computeIfAbsent方法返回的Value是LongAdder是一个线程安全的累加器因此可以直接调用其increment方法进行累加。
**这样在确保线程安全的情况下达到极致性能把之前7行代码替换为了1行。**
我们通过一个简单的测试比较一下修改前后两段代码的性能:
```
@GetMapping(&quot;good&quot;)
public String good() throws InterruptedException {
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;normaluse&quot;);
Map&lt;String, Long&gt; normaluse = normaluse();
stopWatch.stop();
//校验元素数量
Assert.isTrue(normaluse.size() == ITEM_COUNT, &quot;normaluse size error&quot;);
//校验累计总数
Assert.isTrue(normaluse.entrySet().stream()
.mapToLong(item -&gt; item.getValue()).reduce(0, Long::sum) == LOOP_COUNT
, &quot;normaluse count error&quot;);
stopWatch.start(&quot;gooduse&quot;);
Map&lt;String, Long&gt; gooduse = gooduse();
stopWatch.stop();
Assert.isTrue(gooduse.size() == ITEM_COUNT, &quot;gooduse size error&quot;);
Assert.isTrue(gooduse.entrySet().stream()
.mapToLong(item -&gt; item.getValue())
.reduce(0, Long::sum) == LOOP_COUNT
, &quot;gooduse count error&quot;);
log.info(stopWatch.prettyPrint());
return &quot;OK&quot;;
}
```
这段测试代码并无特殊之处使用StopWatch来测试两段代码的性能最后跟了一个断言判断Map中元素的个数以及所有Value的和是否符合预期来校验代码的正确性。测试结果如下
<img src="https://static001.geekbang.org/resource/image/75/3a/751d484ecd8c3114c15588e7fff3263a.png" alt="">
可以看到,**优化后的代码相比使用锁来操作ConcurrentHashMap的方式性能提升了10倍**。
你可能会问computeIfAbsent为什么如此高效呢
答案就在源码最核心的部分也就是Java自带的Unsafe实现的CAS。它在虚拟机层面确保了写入数据的原子性比加锁的效率高得多
```
static final &lt;K,V&gt; boolean casTabAt(Node&lt;K,V&gt;[] tab, int i,
Node&lt;K,V&gt; c, Node&lt;K,V&gt; v) {
return U.compareAndSetObject(tab, ((long)i &lt;&lt; ASHIFT) + ABASE, c, v);
}
```
像ConcurrentHashMap这样的高级并发工具的确提供了一些高级API只有充分了解其特性才能最大化其威力而不能因为其足够高级、酷炫盲目使用。
## 没有认清并发工具的使用场景,因而导致性能问题
除了ConcurrentHashMap这样通用的并发工具类之外我们的工具包中还有些针对特殊场景实现的生面孔。一般来说针对通用场景的通用解决方案在所有场景下性能都还可以属于“万金油”而针对特殊场景的特殊实现会有比通用解决方案更高的性能但一定要在它针对的场景下使用否则可能会产生性能问题甚至是Bug。
之前在排查一个生产性能问题时我们发现一段简单的非数据库操作的业务逻辑消耗了超出预期的时间在修改数据时操作本地缓存比回写数据库慢许多。查看代码发现开发同学使用了CopyOnWriteArrayList来缓存大量的数据而数据变化又比较频繁。
CopyOnWrite是一个时髦的技术不管是Linux还是Redis都会用到。**在Java中CopyOnWriteArrayList虽然是一个线程安全的ArrayList但因为其实现方式是每次修改数据时都会复制一份数据出来所以有明显的适用场景即读多写少或者说希望无锁读的场景。**
如果我们要使用CopyOnWriteArrayList那一定是因为场景需要而不是因为足够酷炫。如果读写比例均衡或者有大量写操作的话使用CopyOnWriteArrayList的性能会非常糟糕。
我们写一段测试代码来比较下使用CopyOnWriteArrayList和普通加锁方式ArrayList的读写性能吧。在这段代码中我们针对并发读和并发写分别写了一个测试方法测试两者一定次数的写或读操作的耗时。
```
//测试并发写的性能
@GetMapping(&quot;write&quot;)
public Map testWrite() {
List&lt;Integer&gt; copyOnWriteArrayList = new CopyOnWriteArrayList&lt;&gt;();
List&lt;Integer&gt; synchronizedList = Collections.synchronizedList(new ArrayList&lt;&gt;());
StopWatch stopWatch = new StopWatch();
int loopCount = 100000;
stopWatch.start(&quot;Write:copyOnWriteArrayList&quot;);
//循环100000次并发往CopyOnWriteArrayList写入随机元素
IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -&gt; copyOnWriteArrayList.add(ThreadLocalRandom.current().nextInt(loopCount)));
stopWatch.stop();
stopWatch.start(&quot;Write:synchronizedList&quot;);
//循环100000次并发往加锁的ArrayList写入随机元素
IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -&gt; synchronizedList.add(ThreadLocalRandom.current().nextInt(loopCount)));
stopWatch.stop();
log.info(stopWatch.prettyPrint());
Map result = new HashMap();
result.put(&quot;copyOnWriteArrayList&quot;, copyOnWriteArrayList.size());
result.put(&quot;synchronizedList&quot;, synchronizedList.size());
return result;
}
//帮助方法用来填充List
private void addAll(List&lt;Integer&gt; list) {
list.addAll(IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList()));
}
//测试并发读的性能
@GetMapping(&quot;read&quot;)
public Map testRead() {
//创建两个测试对象
List&lt;Integer&gt; copyOnWriteArrayList = new CopyOnWriteArrayList&lt;&gt;();
List&lt;Integer&gt; synchronizedList = Collections.synchronizedList(new ArrayList&lt;&gt;());
//填充数据
addAll(copyOnWriteArrayList);
addAll(synchronizedList);
StopWatch stopWatch = new StopWatch();
int loopCount = 1000000;
int count = copyOnWriteArrayList.size();
stopWatch.start(&quot;Read:copyOnWriteArrayList&quot;);
//循环1000000次并发从CopyOnWriteArrayList随机查询元素
IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -&gt; copyOnWriteArrayList.get(ThreadLocalRandom.current().nextInt(count)));
stopWatch.stop();
stopWatch.start(&quot;Read:synchronizedList&quot;);
//循环1000000次并发从加锁的ArrayList随机查询元素
IntStream.range(0, loopCount).parallel().forEach(__ -&gt; synchronizedList.get(ThreadLocalRandom.current().nextInt(count)));
stopWatch.stop();
log.info(stopWatch.prettyPrint());
Map result = new HashMap();
result.put(&quot;copyOnWriteArrayList&quot;, copyOnWriteArrayList.size());
result.put(&quot;synchronizedList&quot;, synchronizedList.size());
return result;
}
```
运行程序可以看到,**大量写的场景10万次add操作****CopyOnWriteArray几乎比同步的ArrayList慢一百倍**
<img src="https://static001.geekbang.org/resource/image/97/b4/9789fe2019a1267b7883606b60e498b4.png" alt="">
而在大量读的场景下100万次get操作CopyOnWriteArray又比同步的ArrayList快五倍以上
<img src="https://static001.geekbang.org/resource/image/30/36/30ba652fb3295c58b03f51de0a132436.png" alt="">
你可能会问为何在大量写的场景下CopyOnWriteArrayList会这么慢呢
答案就在源码中。以add方法为例每次add时都会用Arrays.copyOf创建一个新数组频繁add时内存的申请释放消耗会很大
```
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
}
}
```
## 重点回顾
今天,我主要与你分享了,开发人员使用并发工具来解决线程安全问题时容易犯的四类错。
一是只知道使用并发工具但并不清楚当前线程的来龙去脉解决多线程问题却不了解线程。比如使用ThreadLocal来缓存数据以为ThreadLocal在线程之间做了隔离不会有线程安全问题没想到线程重用导致数据串了。请务必记得在业务逻辑结束之前清理ThreadLocal中的数据。
二是误以为使用了并发工具就可以解决一切线程安全问题期望通过把线程不安全的类替换为线程安全的类来一键解决问题。比如认为使用了ConcurrentHashMap就可以解决线程安全问题没对复合逻辑加锁导致业务逻辑错误。如果你希望在一整段业务逻辑中对容器的操作都保持整体一致性的话需要加锁处理。
三是没有充分了解并发工具的特性还是按照老方式使用新工具导致无法发挥其性能。比如使用了ConcurrentHashMap但没有充分利用其提供的基于CAS安全的方法还是使用锁的方式来实现逻辑。你可以阅读一下[ConcurrentHashMap的文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html)看一下相关原子性操作API是否可以满足业务需求如果可以则优先考虑使用。
四是没有了解清楚工具的适用场景在不合适的场景下使用了错误的工具导致性能更差。比如没有理解CopyOnWriteArrayList的适用场景把它用在了读写均衡或者大量写操作的场景下导致性能问题。对于这种场景你可以考虑是用普通的List。
其实,这四类坑之所以容易踩到,原因可以归结为,我们在使用并发工具的时候,并没有充分理解其可能存在的问题、适用场景等。所以最后,**我还要和你分享两点建议**
1. 一定要认真阅读官方文档比如Oracle JDK文档。充分阅读官方文档理解工具的适用场景及其API的用法并做一些小实验。了解之后再去使用就可以避免大部分坑。
1. 如果你的代码运行在多线程环境下那么就会有并发问题并发问题不那么容易重现可能需要使用压力测试模拟并发场景来发现其中的Bug或性能问题。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 今天我们多次用到了ThreadLocalRandom你觉得是否可以把它的实例设置到静态变量中在多线程情况下重用呢
1. ConcurrentHashMap还提供了putIfAbsent方法你能否通过查阅[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html)说说computeIfAbsent和putIfAbsent方法的区别
你在使用并发工具时,还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,372 @@
<audio id="audio" title="02 | 代码加锁:不要让“锁”事成为烦心事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/68/af92a2dc5157227187e97fcd4c971f68.mp3"></audio>
你好,我是朱晔。
在上一讲中,我与你介绍了使用并发容器等工具解决线程安全的误区。今天,我们来看看解决线程安全问题的另一种重要手段——锁,在使用上比较容易犯哪些错。
我先和你分享一个有趣的案例吧。有一天一位同学在群里说“见鬼了疑似遇到了一个JVM的Bug”我们都很好奇是什么Bug。
于是他贴出了这样一段代码在一个类里有两个int类型的字段a和b有一个add方法循环1万次对a和b进行++操作有另一个compare方法同样循环1万次判断a是否小于b条件成立就打印a和b的值并判断a&gt;b是否成立。
```
@Slf4j
public class Interesting {
volatile int a = 1;
volatile int b = 1;
public void add() {
log.info(&quot;add start&quot;);
for (int i = 0; i &lt; 10000; i++) {
a++;
b++;
}
log.info(&quot;add done&quot;);
}
public void compare() {
log.info(&quot;compare start&quot;);
for (int i = 0; i &lt; 10000; i++) {
//a始终等于b吗
if (a &lt; b) {
log.info(&quot;a:{},b:{},{}&quot;, a, b, a &gt; b);
//最后的a&gt;b应该始终是false吗
}
}
log.info(&quot;compare done&quot;);
}
}
```
他起了两个线程来分别执行add和compare方法
```
Interesting interesting = new Interesting();
new Thread(() -&gt; interesting.add()).start();
new Thread(() -&gt; interesting.compare()).start();
```
按道理a和b同样进行累加操作应该始终相等compare中的第一次判断应该始终不会成立不会输出任何日志。但执行代码后发现不但输出了日志而且更诡异的是compare方法在判断a&lt;b成立的情况下还输出了a&gt;b也成立
<img src="https://static001.geekbang.org/resource/image/9e/1d/9ec61aada64ac6d38681dd199c0ee61d.png" alt="">
群里一位同学看到这个问题笑了“这哪是JVM的Bug分明是线程安全问题嘛。很明显你这是在操作两个字段a和b有线程安全问题应该为add方法加上锁确保a和b的++是原子性的就不会错乱了。”随后他为add方法加上了锁
```
public synchronized void add()
```
但,加锁后问题并没有解决。
我们来仔细想一下,为什么锁可以解决线程安全问题呢。因为只有一个线程可以拿到锁,所以加锁后的代码中的资源操作是线程安全的。但是,**这个案例中的add方法始终只有一个线程在操作显然只为add方法加锁是没用的**。
之所以出现这种错乱是因为两个线程是交错执行add和compare方法中的业务逻辑而且这些业务逻辑不是原子性的a++和b++操作中可以穿插在compare方法的比较代码中更需要注意的是a&lt;b这种比较操作在字节码层面是加载a、加载b和比较三步代码虽然是一行但也不是原子性的。
所以正确的做法应该是为add和compare都加上方法锁确保add方法执行时compare无法读取a和b
```
public synchronized void add()
public synchronized void compare()
```
所以,使用锁解决问题之前一定要理清楚,我们要保护的是什么逻辑,多线程执行的情况又是怎样的。
## 加锁前要清楚锁和被保护的对象是不是一个层面的
除了没有分析清线程、业务逻辑和锁三者之间的关系随意添加无效的方法锁外,还有一种比较常见的错误是,没有理清楚锁和要保护的对象是否是一个层面的。
我们知道**静态字段属于类,类级别的锁才能保护;而非静态字段属于类实例,实例级别的锁就可以保护。**
先看看这段代码有什么问题在类Data中定义了一个静态的int字段counter和一个非静态的wrong方法实现counter字段的累加操作。
```
class Data {
@Getter
private static int counter = 0;
public static int reset() {
counter = 0;
return counter;
}
public synchronized void wrong() {
counter++;
}
}
```
写一段代码测试下:
```
@GetMapping(&quot;wrong&quot;)
public int wrong(@RequestParam(value = &quot;count&quot;, defaultValue = &quot;1000000&quot;) int count) {
Data.reset();
//多线程循环一定次数调用Data类不同实例的wrong方法
IntStream.rangeClosed(1, count).parallel().forEach(i -&gt; new Data().wrong());
return Data.getCounter();
}
```
因为默认运行100万次所以执行后应该输出100万但页面输出的是639242
<img src="https://static001.geekbang.org/resource/image/77/0b/777f520e9d0be89b66e814d3e7c1a30b.png" alt="">
我们来分析下为什么会出现这个问题吧。
在非静态的wrong方法上加锁只能确保多个线程无法执行同一个实例的wrong方法却不能保证不会执行不同实例的wrong方法。而静态的counter在多个实例中共享所以必然会出现线程安全问题。
理清思路后修正方法就很清晰了同样在类中定义一个Object类型的静态字段在操作counter之前对这个字段加锁。
```
class Data {
@Getter
private static int counter = 0;
private static Object locker = new Object();
public void right() {
synchronized (locker) {
counter++;
}
}
}
```
你可能要问了把wrong方法定义为静态不就可以了这个时候锁是类级别的。可以是可以但我们不可能为了解决线程安全问题改变代码结构把实例方法改为静态方法。
感兴趣的同学还可以从字节码以及JVM的层面继续探索一下代码块级别的synchronized和方法上标记synchronized关键字在实现上有什么区别。
## 加锁要考虑锁的粒度和场景问题
在方法上加synchronized关键字实现加锁确实简单也因此我曾看到一些业务代码中几乎所有方法都加了synchronized但这种滥用synchronized的做法
- 一是没必要。通常情况下60%的业务代码是三层架构数据经过无状态的Controller、Service、Repository流转到数据库没必要使用synchronized来保护什么数据。
- 二是可能会极大地降低性能。使用Spring框架时默认情况下Controller、Service、Repository是单例的加上synchronized会导致整个程序几乎就只能支持单线程造成极大的性能问题。
**即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。**
比如在业务代码中有一个ArrayList因为会被多个线程操作而需要保护又有一段比较耗时的操作代码中的slow方法不涉及线程安全问题应该如何加锁呢
错误的做法是给整段业务逻辑加锁把slow方法和操作ArrayList的代码同时纳入synchronized代码块更合适的做法是把加锁的粒度降到最低只在操作ArrayList的时候给这个ArrayList加锁。
```
private List&lt;Integer&gt; data = new ArrayList&lt;&gt;();
//不涉及共享资源的慢方法
private void slow() {
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
}
}
//错误的加锁方法
@GetMapping(&quot;wrong&quot;)
public int wrong() {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, 1000).parallel().forEach(i -&gt; {
//加锁粒度太粗了
synchronized (this) {
slow();
data.add(i);
}
});
log.info(&quot;took:{}&quot;, System.currentTimeMillis() - begin);
return data.size();
}
//正确的加锁方法
@GetMapping(&quot;right&quot;)
public int right() {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, 1000).parallel().forEach(i -&gt; {
slow();
//只对List加锁
synchronized (data) {
data.add(i);
}
});
log.info(&quot;took:{}&quot;, System.currentTimeMillis() - begin);
return data.size();
}
```
执行这段代码同样是1000次业务操作正确加锁的版本耗时1.4秒而对整个业务逻辑加锁的话耗时11秒。
<img src="https://static001.geekbang.org/resource/image/1c/43/1cb278c010719ee00d988dbb2a42c543.png" alt="">
**如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。**
一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我只和你分享几个大概的结论,你可以根据自己的需求来考虑是否有必要进一步优化:
- 对于读写比例差异明显的场景考虑使用ReentrantReadWriteLock细化区分读写锁来提高性能。
- 如果你的JDK版本高于1.8、共享资源的冲突概率也没那么大的话考虑使用StampedLock的乐观读的特性进一步提高性能。
- JDK里ReentrantLock和ReentrantReadWriteLock都提供了公平锁的版本在没有明确需求的情况下不要轻易开启公平锁特性在任务很轻的情况下开启公平锁可能会让性能下降上百倍。
## 多把锁要小心死锁问题
刚才我们聊到锁的粒度够用就好,这就意味着我们的程序逻辑中有时会存在一些细粒度的锁。但一个业务逻辑如果涉及多把锁,容易产生死锁问题。
之前我遇到过这样一个案例:下单操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进行下单扣减库存操作,全部操作完成之后释放所有的锁。代码上线后发现,下单失败概率很高,失败后需要用户重新下单,极大影响了用户体验,还影响到了销量。
经排查发现是死锁引起的问题,背后原因是扣减库存的顺序不同,导致并发的情况下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是出现了死锁问题。
接下来,我们剖析一下核心的业务代码。
首先定义一个商品类型包含商品名、库存剩余和商品的库存锁三个属性每一种商品默认库存1000个然后初始化10个这样的商品对象来模拟商品清单
```
@Data
@RequiredArgsConstructor
static class Item {
final String name; //商品名
int remaining = 1000; //库存剩余
@ToString.Exclude //ToString不包含这个字段
ReentrantLock lock = new ReentrantLock();
}
```
随后写一个方法模拟在购物车进行商品选购每次从商品清单items字段中随机选购三个商品为了逻辑简单我们不考虑每次选购多个同类商品的逻辑购物车中不体现商品数量
```
private List&lt;Item&gt; createCart() {
return IntStream.rangeClosed(1, 3)
.mapToObj(i -&gt; &quot;item&quot; + ThreadLocalRandom.current().nextInt(items.size()))
.map(name -&gt; items.get(name)).collect(Collectors.toList());
}
```
下单代码如下先声明一个List来保存所有获得的锁然后遍历购物车中的商品依次尝试获得商品的锁最长等待10秒获得全部锁之后再扣减库存如果有无法获得锁的情况则解锁之前获得的所有锁返回false下单失败。
```
private boolean createOrder(List&lt;Item&gt; order) {
//存放所有获得的锁
List&lt;ReentrantLock&gt; locks = new ArrayList&lt;&gt;();
for (Item item : order) {
try {
//获得锁10秒超时
if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
locks.add(item.lock);
} else {
locks.forEach(ReentrantLock::unlock);
return false;
}
} catch (InterruptedException e) {
}
}
//锁全部拿到之后执行扣减库存业务逻辑
try {
order.forEach(item -&gt; item.remaining--);
} finally {
locks.forEach(ReentrantLock::unlock);
}
return true;
}
```
我们写一段代码测试这个下单操作。模拟在多线程情况下进行100次创建购物车和下单操作最后通过日志输出成功的下单次数、总剩余的商品个数、100次下单耗时以及下单完成后的商品库存明细
```
@GetMapping(&quot;wrong&quot;)
public long wrong() {
long begin = System.currentTimeMillis();
//并发进行100次下单操作统计成功次数
long success = IntStream.rangeClosed(1, 100).parallel()
.mapToObj(i -&gt; {
List&lt;Item&gt; cart = createCart();
return createOrder(cart);
})
.filter(result -&gt; result)
.count();
log.info(&quot;success:{} totalRemaining:{} took:{}ms items:{}&quot;,
success,
items.entrySet().stream().map(item -&gt; item.getValue().remaining).reduce(0, Integer::sum),
System.currentTimeMillis() - begin, items);
return success;
}
```
运行程序,输出如下日志:
<img src="https://static001.geekbang.org/resource/image/14/05/141a5ed915e08e50c0f6b066bea36e05.png" alt="">
可以看到100次下单操作成功了65次10种商品总计10000件库存总计为9805消耗了195件符合预期65次下单成功每次下单包含三件商品总耗时50秒。
为什么会这样呢?
使用JDK自带的VisualVM工具来跟踪一下重新执行方法后不久就可以看到线程Tab中提示了死锁问题根据提示点击右侧线程Dump按钮进行线程抓取操作
<img src="https://static001.geekbang.org/resource/image/ff/ce/ff24ac10bd0635ef4bf5987038b622ce.png" alt="">
查看抓取出的线程栈,在页面中部可以看到如下日志:
<img src="https://static001.geekbang.org/resource/image/c3/42/c32cb32eb5433aae3b392738a80bca42.png" alt="">
显然,**是出现了死锁线程4在等待的一个锁被线程3持有线程3在等待的另一把锁被线程4持有**。
那为什么会有死锁问题呢?
我们仔细回忆一下购物车添加商品的逻辑随机添加了三种商品假设一个购物车中的商品是item1和item2另一个购物车中的商品是item2和item1一个线程先获取到了item1的锁同时另一个线程获取到了item2的锁然后两个线程接下来要分别获取item2和item1的锁这个时候锁已经被对方获取了只能相互等待一直到10秒超时。
其实,避免死锁的方案很简单,**为购物车中的商品排一下序让所有的线程一定是先获取item1的锁然后获取item2的锁就不会有问题了**。所以我只需要修改一行代码对createCart获得的购物车按照商品名进行排序即可
```
@GetMapping(&quot;right&quot;)
public long right() {
...
.
long success = IntStream.rangeClosed(1, 100).parallel()
.mapToObj(i -&gt; {
List&lt;Item&gt; cart = createCart().stream()
.sorted(Comparator.comparing(Item::getName))
.collect(Collectors.toList());
return createOrder(cart);
})
.filter(result -&gt; result)
.count();
...
return success;
}
```
测试一下right方法不管执行多少次都是100次成功下单而且性能相当高达到了3000以上的TPS
<img src="https://static001.geekbang.org/resource/image/a4/e4/a41d077eeecc8b922503409d13a465e4.png" alt="">
这个案例中,虽然产生了死锁问题,但因为尝试获取锁的操作并不是无限阻塞的,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对购物车的商品进行排序来实现有顺序的加锁,避免循环等待。
## 重点回顾
我们一起总结回顾下,使用锁来解决多线程情况下线程安全问题的坑吧。
第一使用synchronized加锁虽然简单但我们首先要弄清楚共享资源是类还是实例级别的、会被哪些线程操作synchronized关联的锁对象或方法又是什么范围的。
第二加锁尽可能要考虑粒度和场景锁保护的代码意味着无法进行多线程操作。对于Web类型的天然多线程项目对方法进行大范围加锁会显著降级并发能力要考虑尽可能地只为必要的代码块加锁降低锁的粒度而对于要求超高性能的业务还要细化考虑锁的读写场景以及悲观优先还是乐观优先尽可能针对明确场景精细化加锁方案可以在适当的场景下考虑使用ReentrantReadWriteLock、StampedLock等高级的锁工具类。
第三,业务逻辑中有多把锁时要考虑死锁问题,通常的规避方案是,避免无限等待和循环等待。
此外,**如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行**。
为演示方便今天的案例是在Controller的逻辑中开新的线程或使用线程池进行并发模拟我们当然可以意识到哪些对象是并发操作的。但对于Web应用程序的天然多线程场景你可能更容易忽略这点并且也可能因为误用锁降低应用整体的吞吐量。**如果你的业务代码涉及复杂的锁操作强烈建议Mock相关外部接口或数据库操作后对应用代码进行压测通过压测排除锁误用带来的性能问题和死锁问题**。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 本文开头的例子里变量a、b都使用了volatile关键字你知道原因吗我之前遇到过这样一个坑我们开启了一个线程无限循环来跑一些任务有一个bool类型的变量来控制循环的退出默认为true代表执行一段时间后主线程将这个变量设置为了false。如果这个变量不是volatile修饰的子线程可以退出吗你能否解释其中的原因呢
1. 文末我们又提了两个坑,一是加锁和释放没有配对的问题,二是锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两种问题吗?
在使用锁的过程中,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,409 @@
<audio id="audio" title="03 | 线程池:业务代码最常用也最容易犯错的组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/51/1d1be5eea91a86f842844c6fc6471551.mp3"></audio>
你好,我是朱晔。今天,我来讲讲使用线程池需要注意的一些问题。
在程序中,我们会用各种池化技术来缓存创建昂贵的对象,比如线程池、连接池、内存池。一般是预先创建一些对象放入池中,使用的时候直接取出使用,用完归还以便复用,还会通过一定的策略调整池中缓存对象的数量,实现池的动态伸缩。
由于线程的创建比较昂贵,随意、没有控制地创建大量线程会造成性能问题,因此短平快的任务一般考虑使用线程池来处理,而不是直接创建线程。
今天,我们就针对线程池这个话题展开讨论,通过三个生产事故,来看看使用线程池应该注意些什么。
## 线程池的声明需要手动进行
Java中的Executors类定义了一些快捷的工具方法来帮助我们快速创建线程池。《阿里巴巴Java开发手册》中提到禁止使用这些方法来创建线程池而应该手动new ThreadPoolExecutor来创建线程池。这一条规则的背后是大量血淋淋的生产事故最典型的就是newFixedThreadPool和newCachedThreadPool可能因为资源耗尽导致OOM问题。
首先我们来看一下newFixedThreadPool为什么可能会出现OOM的问题。
我们写一段测试代码来初始化一个单线程的FixedThreadPool循环1亿次向线程池提交任务每个任务都会创建一个比较大的字符串然后休眠一小时
```
@GetMapping(&quot;oom1&quot;)
public void oom1() throws InterruptedException {
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
//打印线程池的信息,稍后我会解释这段代码
printStats(threadPool);
for (int i = 0; i &lt; 100000000; i++) {
threadPool.execute(() -&gt; {
String payload = IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString();
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
}
log.info(payload);
});
}
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}
```
执行程序后不久日志中就出现了如下OOM
```
Exception in thread &quot;http-nio-45678-ClientPoller&quot; java.lang.OutOfMemoryError: GC overhead limit exceeded
```
翻看newFixedThreadPool方法的源码不难发现线程池的工作队列直接new了一个LinkedBlockingQueue**而默认构造方法的LinkedBlockingQueue是一个Integer.MAX_VALUE长度的队列可以认为是无界的**
```
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue&lt;Runnable&gt;());
}
public class LinkedBlockingQueue&lt;E&gt; extends AbstractQueue&lt;E&gt;
implements BlockingQueue&lt;E&gt;, java.io.Serializable {
...
/**
* Creates a {@code LinkedBlockingQueue} with a capacity of
* {@link Integer#MAX_VALUE}.
*/
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
...
}
```
虽然使用newFixedThreadPool可以把工作线程控制在固定的数量上但任务队列是无界的。如果任务较多并且执行较慢的话队列可能会快速积压撑爆内存导致OOM。
我们再把刚才的例子稍微改一下改为使用newCachedThreadPool方法来获得线程池。程序运行不久后同样看到了如下OOM异常
```
[11:30:30.487] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.OutOfMemoryError: unable to create new native thread] with root cause
java.lang.OutOfMemoryError: unable to create new native thread
```
从日志中可以看到这次OOM的原因是无法创建线程翻看newCachedThreadPool的源码可以看到**这种线程池的最大线程数是Integer.MAX_VALUE可以认为是没有上限的而其工作队列SynchronousQueue是一个没有存储空间的阻塞队列**。这意味着,只要有请求到来,就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的。
由于我们的任务需要1小时才能执行完成大量的任务进来后会创建大量的线程。我们知道线程是需要分配一定的内存空间作为线程栈的比如1MB因此无限制创建线程必然会导致OOM
```
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue&lt;Runnable&gt;());
```
其实大部分Java开发同学知道这两种线程池的特性只是抱有侥幸心理觉得只是使用线程池做一些轻量级的任务不可能造成队列积压或开启大量线程。
现实往往是残酷的。我之前就遇到过这么一个事故用户注册后我们调用一个外部服务去发送短信发送短信接口正常时可以在100毫秒内响应TPS 100的注册量CachedThreadPool能稳定在占用10个左右线程的情况下满足需求。在某个时间点外部短信服务不可用了我们调用这个服务的超时又特别长比如1分钟1分钟可能就进来了6000用户产生6000个发送短信的任务需要6000个线程没多久就因为无法创建线程导致了OOM整个应用程序崩溃。
因此,**我同样不建议使用Executors提供的两种快捷的线程池原因如下**
- 我们需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
- 任何时候都应该为自定义线程池指定有意义的名称以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量CPU、线程执行出现异常等问题时我们往往会抓取线程栈。此时有意义的线程名称就可以方便我们定位问题。
除了建议手动声明线程池以外,我还建议**用一些监控手段来观察线程池的状态**。线程池这个组件往往会表现得任劳任怨、默默无闻,除非是出现了拒绝策略,否则压力再大都不会抛出一个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。
## 线程池线程管理策略详解
在之前的Demo中我们用一个printStats方法实现了最简陋的监控每秒输出一次线程池的基本内部信息包括线程数、活跃线程数、完成了多少任务以及队列中还有多少积压任务等信息
```
private void printStats(ThreadPoolExecutor threadPool) {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -&gt; {
log.info(&quot;=========================&quot;);
log.info(&quot;Pool Size: {}&quot;, threadPool.getPoolSize());
log.info(&quot;Active Threads: {}&quot;, threadPool.getActiveCount());
log.info(&quot;Number of Tasks Completed: {}&quot;, threadPool.getCompletedTaskCount());
log.info(&quot;Number of Tasks in Queue: {}&quot;, threadPool.getQueue().size());
log.info(&quot;=========================&quot;);
}, 0, 1, TimeUnit.SECONDS);
}
```
接下来,我们就利用这个方法来观察一下线程池的基本特性吧。
首先自定义一个线程池。这个线程池具有2个核心线程、5个最大线程、使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列使用默认的AbortPolicy拒绝策略也就是任务添加到线程池失败会抛出RejectedExecutionException。此外我们借助了Jodd类库的ThreadFactoryBuilder方法来构造一个线程工厂实现线程池线程的自定义命名。
然后我们写一段测试代码来观察线程池管理线程的策略。测试代码的逻辑为每次间隔1秒向线程池提交任务循环20次每个任务需要10秒才能执行完成代码如下
```
@GetMapping(&quot;right&quot;)
public int right() throws InterruptedException {
//使用一个计数器跟踪完成的任务数
AtomicInteger atomicInteger = new AtomicInteger();
//创建一个具有2个核心线程、5个最大线程使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列的线程池使用默认的AbortPolicy拒绝策略
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, 5,
5, TimeUnit.SECONDS,
new ArrayBlockingQueue&lt;&gt;(10),
new ThreadFactoryBuilder().setNameFormat(&quot;demo-threadpool-%d&quot;).get(),
new ThreadPoolExecutor.AbortPolicy());
printStats(threadPool);
//每隔1秒提交一次一共提交20次任务
IntStream.rangeClosed(1, 20).forEach(i -&gt; {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
int id = atomicInteger.incrementAndGet();
try {
threadPool.submit(() -&gt; {
log.info(&quot;{} started&quot;, id);
//每个任务耗时10秒
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
}
log.info(&quot;{} finished&quot;, id);
});
} catch (Exception ex) {
//提交出现异常的话,打印出错信息并为计数器减一
log.error(&quot;error submitting task {}&quot;, id, ex);
atomicInteger.decrementAndGet();
}
});
TimeUnit.SECONDS.sleep(60);
return atomicInteger.intValue();
}
```
60秒后页面输出了17有3次提交失败了
<img src="https://static001.geekbang.org/resource/image/4b/2c/4b820e0b24ce0deefbf2dd7af295c32c.png" alt="">
并且日志中也出现了3次类似的错误信息
```
[14:24:52.879] [http-nio-45678-exec-1] [ERROR] [.t.c.t.demo1.ThreadPoolOOMController:103 ] - error submitting task 18
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@163a2dec rejected from java.util.concurrent.ThreadPoolExecutor@18061ad2[Running, pool size = 5, active threads = 5, queued tasks = 10, completed tasks = 2]
```
我们把printStats方法打印出的日志绘制成图表得出如下曲线
<img src="https://static001.geekbang.org/resource/image/d8/1e/d819035f60bf1c0022a98051d50e031e.png" alt="">
**至此,我们可以总结出线程池默认的工作行为**
- 不会初始化corePoolSize个线程有任务来了才创建工作线程
- 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
- 当工作队列满了后扩容线程池一直到线程个数达到maximumPoolSize为止
- 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;
- 当线程数大于核心线程数时线程等待keepAliveTime后还是没有任务需要处理的话收缩线程到核心线程数。
了解这个策略,有助于我们根据实际的容量规划需求,为线程池设置合适的初始化参数。当然,我们也可以通过一些手段来改变这些默认工作行为,比如:
- 声明线程池后立即调用prestartAllCoreThreads方法来启动所有核心线程
- 传入true给allowCoreThreadTimeOut方法来让线程池在空闲的时候同样回收核心线程。
不知道你有没有想过Java线程池是先用工作队列来存放来不及处理的任务满了之后再扩容线程池。当我们的工作队列设置得很大时最大线程数这个参数显得没有意义因为队列很难满或者到满的时候再去扩容线程池已经于事无补了。
那么,**我们有没有办法让线程池****更激进一点,优先开启更多的线程,而把队列当成一个后备方案呢?**比如我们这个例子任务执行得很慢需要10秒如果线程池可以优先扩容到5个最大线程那么这些任务最终都可以完成而不会因为线程池扩容过晚导致慢任务来不及处理。
限于篇幅,这里我只给你一个大致思路:
1. 由于线程池在工作队列满了无法入队的情况下会扩容线程池那么我们是否可以重写队列的offer方法造成这个队列已满的假象呢
1. 由于我们Hack了队列在达到了最大线程后势必会触发拒绝策略那么能否实现一个自定义的拒绝策略处理程序这个时候再把任务真正插入队列呢
接下来就请你动手试试看如何实现这样一个“弹性”线程池吧。Tomcat线程池也实现了类似的效果可供你借鉴。
## 务必确认清楚线程池本身是不是复用的
不久之前我遇到了这样一个事故某项目生产环境时不时有报警提示线程数过多超过2000个收到报警后查看监控发现瞬时线程数比较多但过一会儿又会降下来线程数抖动很厉害而应用的访问量变化不大。
为了定位问题我们在线程数比较高的时候进行线程栈抓取抓取后发现内存中有1000多个自定义线程池。一般而言线程池肯定是复用的有5个以内的线程池都可以认为正常而1000多个线程池肯定不正常。
在项目代码里我们没有搜到声明线程池的地方搜索execute关键字后定位到原来是业务代码调用了一个类库来获得线程池类似如下的业务代码调用ThreadPoolHelper的getThreadPool方法来获得线程池然后提交数个任务到线程池处理看不出什么异常。
```
@GetMapping(&quot;wrong&quot;)
public String wrong() throws InterruptedException {
ThreadPoolExecutor threadPool = ThreadPoolHelper.getThreadPool();
IntStream.rangeClosed(1, 10).forEach(i -&gt; {
threadPool.execute(() -&gt; {
...
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
});
});
return &quot;OK&quot;;
}
```
但是来到ThreadPoolHelper的实现让人大跌眼镜**getThreadPool方法居然是每次都使用Executors.newCachedThreadPool来创建一个线程池**。
```
class ThreadPoolHelper {
public static ThreadPoolExecutor getThreadPool() {
//线程池没有复用
return (ThreadPoolExecutor) Executors.newCachedThreadPool();
}
}
```
通过上一小节的学习我们可以想到newCachedThreadPool会在需要时创建必要多的线程业务代码的一次业务操作会向线程池提交多个慢任务这样执行一次业务操作就会开启多个线程。如果业务操作并发量较大的话的确有可能一下子开启几千个线程。
那,为什么我们能在监控中看到线程数量会下降,而不会撑爆内存呢?
回到newCachedThreadPool的定义就会发现它的核心线程数是0而keepAliveTime是60秒也就是在60秒之后所有的线程都是可以回收的。好吧就因为这个特性我们的业务程序死得没太难看。
要修复这个Bug也很简单使用一个静态字段来存放线程池的引用返回线程池的代码直接返回这个静态字段即可。这里一定要记得我们的最佳实践手动创建线程池。修复后的ThreadPoolHelper类如下
```
class ThreadPoolHelper {
private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
10, 50,
2, TimeUnit.SECONDS,
new ArrayBlockingQueue&lt;&gt;(1000),
new ThreadFactoryBuilder().setNameFormat(&quot;demo-threadpool-%d&quot;).get());
public static ThreadPoolExecutor getRightThreadPool() {
return threadPoolExecutor;
}
}
```
## 需要仔细斟酌线程池的混用策略
线程池的意义在于复用,那这是不是意味着程序应该始终使用一个线程池呢?
当然不是。通过第一小节的学习我们知道,**要根据任务的“轻重缓急”来指定线程池的核心参数,包括线程数、回收策略和任务队列**
- 对于执行比较慢、数量不大的IO任务或许要考虑更多的线程数而不需要太大的队列。
- 而对于吞吐量较大的计算型任务线程数量不宜过多可以是CPU核数或核数*2理由是线程一定调度到某个CPU进行执行如果任务本身是CPU绑定的任务那么过多的线程只会增加线程切换的开销并不能提升吞吐量但可能需要较长的队列来做缓冲。
之前我也遇到过这么一个问题业务代码使用了线程池异步处理一些内存中的数据但通过监控发现处理得非常慢整个处理过程都是内存中的计算不涉及IO操作也需要数秒的处理时间应用程序CPU占用也不是特别高有点不可思议。
经排查发现,业务代码使用的线程池,还被一个后台的文件批处理任务用到了。
或许是够用就好的原则这个线程池只有2个核心线程最大线程也是2使用了容量为100的ArrayBlockingQueue作为工作队列使用了CallerRunsPolicy拒绝策略
```
private static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, 2,
1, TimeUnit.HOURS,
new ArrayBlockingQueue&lt;&gt;(100),
new ThreadFactoryBuilder().setNameFormat(&quot;batchfileprocess-threadpool-%d&quot;).get(),
new ThreadPoolExecutor.CallerRunsPolicy());
```
这里,我们模拟一下文件批处理的代码,在程序启动后通过一个线程开启死循环逻辑,不断向线程池提交任务,任务的逻辑是向一个文件中写入大量的数据:
```
@PostConstruct
public void init() {
printStats(threadPool);
new Thread(() -&gt; {
//模拟需要写入的大量数据
String payload = IntStream.rangeClosed(1, 1_000_000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;));
while (true) {
threadPool.execute(() -&gt; {
try {
//每次都是创建并写入相同的数据到相同的文件
Files.write(Paths.get(&quot;demo.txt&quot;), Collections.singletonList(LocalTime.now().toString() + &quot;:&quot; + payload), UTF_8, CREATE, TRUNCATE_EXISTING);
} catch (IOException e) {
e.printStackTrace();
}
log.info(&quot;batch file processing done&quot;);
});
}
}).start();
}
```
可以想象到这个线程池中的2个线程任务是相当重的。通过printStats方法打印出的日志我们观察下线程池的负担
<img src="https://static001.geekbang.org/resource/image/49/55/49c132595db60f109530e0dec55ccd55.png" alt="">
可以看到,**线程池的2个线程始终处于活跃状态队列也基本处于打满状态。**因为开启了CallerRunsPolicy拒绝处理策略所以当线程满载队列也满的情况下任务会在提交任务的线程或者说调用execute方法的线程执行也就是说不能认为提交到线程池的任务就一定是异步处理的。如果使用了CallerRunsPolicy策略那么有可能异步任务变为同步执行。从日志的第四行也可以看到这点。这也是这个拒绝策略比较特别的原因。
不知道写代码的同学为什么设置这个策略,或许是测试时发现线程池因为任务处理不过来出现了异常,而又不希望线程池丢弃任务,所以最终选择了这样的拒绝策略。不管怎样,这些日志足以说明线程池是饱和状态。
可以想象到业务代码复用这样的线程池来做内存计算命运一定是悲惨的。我们写一段代码测试下向线程池提交一个简单的任务这个任务只是休眠10毫秒没有其他逻辑
```
private Callable&lt;Integer&gt; calcTask() {
return () -&gt; {
TimeUnit.MILLISECONDS.sleep(10);
return 1;
};
}
@GetMapping(&quot;wrong&quot;)
public int wrong() throws ExecutionException, InterruptedException {
return threadPool.submit(calcTask()).get();
}
```
我们使用wrk工具对这个接口进行一个简单的压测可以看到TPS为75性能的确非常差。
<img src="https://static001.geekbang.org/resource/image/98/07/989f7ab383e59e21751adb77a9b53507.png" alt="">
细想一下问题其实没有这么简单。因为原来执行IO任务的线程池使用的是CallerRunsPolicy策略所以直接使用这个线程池进行异步计算的话**当线程池饱和的时候计算任务会在执行Web请求的Tomcat线程执行这时就会进一步影响到其他同步处理的线程甚至造成整个应用程序崩溃**。
解决方案很简单使用独立的线程池来做这样的“计算任务”即可。计算任务打了双引号是因为我们的模拟代码执行的是休眠操作并不属于CPU绑定的操作更类似IO绑定的操作如果线程池线程数设置太小会限制吞吐能力
```
private static ThreadPoolExecutor asyncCalcThreadPool = new ThreadPoolExecutor(
200, 200,
1, TimeUnit.HOURS,
new ArrayBlockingQueue&lt;&gt;(1000),
new ThreadFactoryBuilder().setNameFormat(&quot;asynccalc-threadpool-%d&quot;).get());
@GetMapping(&quot;right&quot;)
public int right() throws ExecutionException, InterruptedException {
return asyncCalcThreadPool.submit(calcTask()).get();
}
```
使用单独的线程池改造代码后再来测试一下性能TPS提高到了1727
<img src="https://static001.geekbang.org/resource/image/c2/06/c21eed38ccd18758d38745dd09496a06.png" alt="">
可以看到,盲目复用线程池混用线程的问题在于,别人定义的线程池属性不一定适合你的任务,而且混用会相互干扰。这就好比,我们往往会用虚拟化技术来实现资源的隔离,而不是让所有应用程序都直接使用物理机。
就线程池混用问题,我想再和你补充一个坑:**Java 8的parallel stream功能可以让我们很方便地并行处理集合中的元素其背后是共享同一个ForkJoinPool默认并行度是CPU核数-1**。对于CPU绑定的任务来说使用这样的配置比较合适但如果集合操作涉及同步IO操作的话比如数据库操作、外部服务调用等建议自定义一个ForkJoinPool或普通线程池。你可以参考[第一讲](https://time.geekbang.org/column/article/209494)的相关Demo。
## 重点回顾
线程池管理着线程,线程又属于宝贵的资源,有许多应用程序的性能问题都来自线程池的配置和使用不当。在今天的学习中,我通过三个和线程池相关的生产事故,和你分享了使用线程池的几个最佳实践。
第一Executors类提供的一些快捷声明线程池的方法虽然简单但隐藏了线程池的参数细节。因此使用线程池时我们一定要根据场景和需求配置合理的线程数、任务队列、拒绝策略、线程回收策略并对线程进行明确的命名方便排查问题。
第二既然使用了线程池就需要确保线程池是在复用的每次new一个线程池出来可能比不用线程池还糟糕。如果你没有直接声明线程池而是使用其他同学提供的类库来获得一个线程池请务必查看源码以确认线程池的实例化方式和配置是符合预期的。
第三复用线程池不代表应用程序始终使用同一个线程池我们应该根据任务的性质来选用不同的线程池。特别注意IO绑定的任务和CPU绑定的任务对于线程池属性的偏好如果希望减少任务间的相互干扰考虑按需使用隔离的线程池。
最后我想强调的是,**线程池作为应用程序内部的核心组件往往缺乏监控**如果你使用类似RabbitMQ这样的MQ中间件运维同学一般会帮我们做好中间件监控往往到程序崩溃后才发现线程池的问题很被动。在设计篇中我们会重新谈及这个问题及其解决方案。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在第一节中我们提到,或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?
1. 在第二节中我们改进了ThreadPoolHelper使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池10核心线程50最大线程2秒回收的反复执行测试接口线程最终可以被回收吗会出现OOM问题吗
你还遇到过线程池相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,498 @@
<audio id="audio" title="04 | 连接池:别让连接池帮了倒忙" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/16/3de8fa9752b9c1daa08ce42ae15d3b16.mp3"></audio>
你好,我是朱晔。今天,我们来聊聊使用连接池需要注意的问题。
在上一讲,我们学习了使用线程池需要注意的问题。今天,我再与你说说另一种很重要的池化技术,即连接池。
我先和你说说连接池的结构。连接池一般对外提供获得连接、归还连接的接口给客户端使用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。连接池的结构示意图,如下所示:
<img src="https://static001.geekbang.org/resource/image/16/7e/1685d9db2602e1de8483de171af6fd7e.png" alt="">
业务项目中经常会用到的连接池主要是数据库连接池、Redis连接池和HTTP连接池。所以今天我就以这三种连接池为例和你聊聊使用和配置连接池容易出错的地方。
## 注意鉴别客户端SDK是否基于连接池
在使用三方客户端进行网络通信时我们首先要确定客户端SDK是否是基于连接池技术实现的。我们知道TCP是面向连接的基于字节流的协议
- 面向连接,意味着连接需要先创建再使用,创建连接的三次握手有一定开销;
- 基于字节流意味着字节是发送数据的最小单元TCP协议本身无法区分哪几个字节是完整的消息体也无法感知是否有多个客户端在使用同一个TCP连接TCP只是一个读写数据的管道。
如果客户端SDK没有使用连接池而直接是TCP连接那么就需要考虑每次建立TCP连接的开销**并且因为TCP基于字节流在多线程的情况下对同一连接进行复用可能会产生线程安全问题**。
我们先看一下涉及TCP连接的客户端SDK对外提供API的三种方式。在面对各种三方客户端的时候只有先识别出其属于哪一种才能理清楚使用方式。
- 连接池和连接分离的API有一个XXXPool类负责连接池实现先从其获得连接XXXConnection然后用获得的连接进行服务端请求完成后使用者需要归还连接。通常XXXPool是线程安全的可以并发获取和归还连接而XXXConnection是非线程安全的。对应到连接池的结构示意图中XXXPool就是右边连接池那个框左边的客户端是我们自己的代码。
- 内部带有连接池的API对外提供一个XXXClient类通过这个类可以直接进行服务端请求这个类内部维护了连接池SDK使用者无需考虑连接的获取和归还问题。一般而言XXXClient是线程安全的。对应到连接池的结构示意图中整个API就是蓝色框包裹的部分。
- 非连接池的API一般命名为XXXConnection以区分其是基于连接池还是单连接的而不建议命名为XXXClient或直接是XXX。直接连接方式的API基于单一连接每次使用都需要创建和断开连接性能一般且通常不是线程安全的。对应到连接池的结构示意图中这种形式相当于没有右边连接池那个框客户端直接连接服务端创建连接。
虽然上面提到了SDK一般的命名习惯但不排除有一些客户端特立独行因此在使用三方SDK时一定要先查看官方文档了解其最佳实践或是在类似Stackoverflow的网站搜索XXX threadsafe/singleton字样看看大家的回复也可以一层一层往下看源码直到定位到原始Socket来判断Socket和客户端API的对应关系。
明确了SDK连接池的实现方式后我们就大概知道了使用SDK的最佳实践
- 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接池获取连接,使用后归还,归还的工作由使用者负责。
- 如果是内置连接池SDK会负责连接的获取和归还使用的时候直接复用客户端。
- 如果SDK没有实现连接池大多数中间件、数据库的客户端SDK都会支持连接池那通常不是线程安全的而且短连接的方式性能不会很高使用的时候需要考虑是否自己封装一个连接池。
接下来我就以Java中用于操作Redis最常见的库Jedis为例从源码角度分析下Jedis类到底属于哪种类型的API直接在多线程环境下复用一个连接会产生什么问题以及如何用最佳实践来修复这个问题。
首先向Redis初始化2组数据Key=a、Value=1Key=b、Value=2
```
@PostConstruct
public void init() {
try (Jedis jedis = new Jedis(&quot;127.0.0.1&quot;, 6379)) {
Assert.isTrue(&quot;OK&quot;.equals(jedis.set(&quot;a&quot;, &quot;1&quot;)), &quot;set a = 1 return OK&quot;);
Assert.isTrue(&quot;OK&quot;.equals(jedis.set(&quot;b&quot;, &quot;2&quot;)), &quot;set b = 2 return OK&quot;);
}
}
```
然后启动两个线程共享操作同一个Jedis实例每一个线程循环1000次分别读取Key为a和b的Value判断是否分别为1和2
```
Jedis jedis = new Jedis(&quot;127.0.0.1&quot;, 6379);
new Thread(() -&gt; {
for (int i = 0; i &lt; 1000; i++) {
String result = jedis.get(&quot;a&quot;);
if (!result.equals(&quot;1&quot;)) {
log.warn(&quot;Expect a to be 1 but found {}&quot;, result);
return;
}
}
}).start();
new Thread(() -&gt; {
for (int i = 0; i &lt; 1000; i++) {
String result = jedis.get(&quot;b&quot;);
if (!result.equals(&quot;2&quot;)) {
log.warn(&quot;Expect b to be 2 but found {}&quot;, result);
return;
}
}
}).start();
TimeUnit.SECONDS.sleep(5);
```
执行程序多次可以看到日志中出现了各种奇怪的异常信息有的是读取Key为b的Value读取到了1有的是流非正常结束还有的是连接关闭异常
```
//错误1
[14:56:19.069] [Thread-28] [WARN ] [.t.c.c.redis.JedisMisreuseController:45 ] - Expect b to be 2 but found 1
//错误2
redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
at redis.clients.jedis.util.RedisInputStream.ensureFill(RedisInputStream.java:202)
at redis.clients.jedis.util.RedisInputStream.readLine(RedisInputStream.java:50)
at redis.clients.jedis.Protocol.processError(Protocol.java:114)
at redis.clients.jedis.Protocol.process(Protocol.java:166)
at redis.clients.jedis.Protocol.read(Protocol.java:220)
at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:318)
at redis.clients.jedis.Connection.getBinaryBulkReply(Connection.java:255)
at redis.clients.jedis.Connection.getBulkReply(Connection.java:245)
at redis.clients.jedis.Jedis.get(Jedis.java:181)
at org.geekbang.time.commonmistakes.connectionpool.redis.JedisMisreuseController.lambda$wrong$1(JedisMisreuseController.java:43)
at java.lang.Thread.run(Thread.java:748)
//错误3
java.io.IOException: Socket Closed
at java.net.AbstractPlainSocketImpl.getOutputStream(AbstractPlainSocketImpl.java:440)
at java.net.Socket$3.run(Socket.java:954)
at java.net.Socket$3.run(Socket.java:952)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.Socket.getOutputStream(Socket.java:951)
at redis.clients.jedis.Connection.connect(Connection.java:200)
... 7 more
```
让我们分析一下Jedis类的源码搞清楚其中缘由吧。
```
public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands,
AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands, ModuleCommands {
}
public class BinaryJedis implements BasicCommands, BinaryJedisCommands, MultiKeyBinaryCommands,
AdvancedBinaryJedisCommands, BinaryScriptingCommands, Closeable {
protected Client client = null;
...
}
public class Client extends BinaryClient implements Commands {
}
public class BinaryClient extends Connection {
}
public class Connection implements Closeable {
private Socket socket;
private RedisOutputStream outputStream;
private RedisInputStream inputStream;
}
```
可以看到Jedis继承了BinaryJedisBinaryJedis中保存了单个Client的实例Client最终继承了ConnectionConnection中保存了单个Socket的实例和Socket对应的两个读写流。因此一个Jedis对应一个Socket连接。类图如下
<img src="https://static001.geekbang.org/resource/image/e7/0f/e72120b1f6daf4a951e75c05b9191a0f.png" alt="">
BinaryClient封装了各种Redis命令其最终会调用基类Connection的方法使用Protocol类发送命令。看一下Protocol类的sendCommand方法的源码可以发现其发送命令时是直接操作RedisOutputStream写入字节。
我们在多线程环境下复用Jedis对象其实就是在复用RedisOutputStream。**如果多个线程在执行操作那么既无法确保整条命令以一个原子操作写入Socket也无法确保写入后、读取前没有其他数据写到远端**
```
private static void sendCommand(final RedisOutputStream os, final byte[] command,
final byte[]... args) {
try {
os.write(ASTERISK_BYTE);
os.writeIntCrLf(args.length + 1);
os.write(DOLLAR_BYTE);
os.writeIntCrLf(command.length);
os.write(command);
os.writeCrLf();
for (final byte[] arg : args) {
os.write(DOLLAR_BYTE);
os.writeIntCrLf(arg.length);
os.write(arg);
os.writeCrLf();
}
} catch (IOException e) {
throw new JedisConnectionException(e);
}
}
```
看到这里我们也可以理解了为啥多线程情况下使用Jedis对象操作Redis会出现各种奇怪的问题。
比如写操作互相干扰多条命令相互穿插的话必然不是合法的Redis命令那么Redis会关闭客户端连接导致连接断开又比如线程1和2先后写入了get a和get b操作的请求Redis也返回了值1和2但是线程2先读取了数据1就会出现数据错乱的问题。
修复方式是使用Jedis提供的另一个线程安全的类JedisPool来获得Jedis的实例。JedisPool可以声明为static在多个线程之间共享扮演连接池的角色。使用时按需使用try-with-resources模式从JedisPool获得和归还Jedis实例。
```
private static JedisPool jedisPool = new JedisPool(&quot;127.0.0.1&quot;, 6379);
new Thread(() -&gt; {
try (Jedis jedis = jedisPool.getResource()) {
for (int i = 0; i &lt; 1000; i++) {
String result = jedis.get(&quot;a&quot;);
if (!result.equals(&quot;1&quot;)) {
log.warn(&quot;Expect a to be 1 but found {}&quot;, result);
return;
}
}
}
}).start();
new Thread(() -&gt; {
try (Jedis jedis = jedisPool.getResource()) {
for (int i = 0; i &lt; 1000; i++) {
String result = jedis.get(&quot;b&quot;);
if (!result.equals(&quot;2&quot;)) {
log.warn(&quot;Expect b to be 2 but found {}&quot;, result);
return;
}
}
}
}).start();
```
这样修复后代码不再有线程安全问题了。此外我们最好通过shutdownhook在程序退出之前关闭JedisPool
```
@PostConstruct
public void init() {
Runtime.getRuntime().addShutdownHook(new Thread(() -&gt; {
jedisPool.close();
}));
}
```
看一下Jedis类close方法的实现可以发现如果Jedis是从连接池获取的话那么close方法会调用连接池的return方法归还连接
```
public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands,
AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands, ModuleCommands {
protected JedisPoolAbstract dataSource = null;
@Override
public void close() {
if (dataSource != null) {
JedisPoolAbstract pool = this.dataSource;
this.dataSource = null;
if (client.isBroken()) {
pool.returnBrokenResource(this);
} else {
pool.returnResource(this);
}
} else {
super.close();
}
}
}
```
如果不是则直接关闭连接其最终调用Connection类的disconnect方法来关闭TCP连接
```
public void disconnect() {
if (isConnected()) {
try {
outputStream.flush();
socket.close();
} catch (IOException ex) {
broken = true;
throw new JedisConnectionException(ex);
} finally {
IOUtils.closeQuietly(socket);
}
}
}
```
可以看到Jedis可以独立使用也可以配合连接池使用这个连接池就是JedisPool。我们再看看JedisPool的实现。
```
public class JedisPool extends JedisPoolAbstract {
@Override
public Jedis getResource() {
Jedis jedis = super.getResource();
jedis.setDataSource(this);
return jedis;
}
@Override
protected void returnResource(final Jedis resource) {
if (resource != null) {
try {
resource.resetState();
returnResourceObject(resource);
} catch (Exception e) {
returnBrokenResource(resource);
throw new JedisException(&quot;Resource is returned to the pool as broken&quot;, e);
}
}
}
}
public class JedisPoolAbstract extends Pool&lt;Jedis&gt; {
}
public abstract class Pool&lt;T&gt; implements Closeable {
protected GenericObjectPool&lt;T&gt; internalPool;
}
```
JedisPool的getResource方法在拿到Jedis对象后将自己设置为了连接池。连接池JedisPool继承了JedisPoolAbstract而后者继承了抽象类PoolPool内部维护了Apache Common的通用池GenericObjectPool。JedisPool的连接池就是基于GenericObjectPool的。
看到这里我们了解了Jedis的API实现是我们说的三种类型中的第一种也就是连接池和连接分离的APIJedisPool是线程安全的连接池Jedis是非线程安全的单一连接。知道了原理之后我们再使用Jedis就胸有成竹了。
## 使用连接池务必确保复用
在介绍[线程池](https://time.geekbang.org/column/article/210337)的时候我们强调过,**池一定是用来复用的,否则其使用代价会比每次创建单一对象更大。对连接池来说更是如此,原因如下:**
- 创建连接池的时候很可能一次性创建了多个连接大多数连接池考虑到性能会在初始化的时候维护一定数量的最小连接毕竟初始化连接池的过程一般是一次性的可以直接使用。如果每次使用连接池都按需创建连接池那么很可能你只用到一个连接但是创建了N个连接。
- 连接池一般会有一些管理模块也就是连接池的结构示意图中的绿色部分。举个例子大多数的连接池都有闲置超时的概念。连接池会检测连接的闲置时间定期回收闲置的连接把活跃连接数降到最低闲置连接的配置值减轻服务端的压力。一般情况下闲置连接由独立线程管理启动了空闲检测的连接池相当于还会启动一个线程。此外有些连接池还需要独立线程负责连接保活等功能。因此启动一个连接池相当于启动了N个线程。
除了使用代价连接池不释放还可能会引起线程泄露。接下来我就以Apache HttpClient为例和你说说连接池不复用的问题。
首先创建一个CloseableHttpClient设置使用PoolingHttpClientConnectionManager连接池并启用空闲连接驱逐策略最大空闲时间为60秒然后使用这个连接来请求一个会返回OK字符串的服务端接口
```
@GetMapping(&quot;wrong1&quot;)
public String wrong1() {
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(new PoolingHttpClientConnectionManager())
.evictIdleConnections(60, TimeUnit.SECONDS).build();
try (CloseableHttpResponse response = client.execute(new HttpGet(&quot;http://127.0.0.1:45678/httpclientnotreuse/test&quot;))) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
```
访问这个接口几次后查看应用线程情况可以看到有大量叫作Connection evictor的线程且这些线程不会销毁
<img src="https://static001.geekbang.org/resource/image/33/10/33a2389c20653e97b8157897d06c1510.png" alt="">
对这个接口进行几秒的压测压测使用wrk1个并发1个连接可以看到已经建立了三千多个TCP连接到45678端口其中有1个是压测客户端到Tomcat的连接大部分都是HttpClient到Tomcat的连接
<img src="https://static001.geekbang.org/resource/image/54/f2/54a71ee9a7bbbd5e121b12fe6289aff2.png" alt="">
好在有了空闲连接回收的策略60秒之后连接处于CLOSE_WAIT状态最终彻底关闭。
<img src="https://static001.geekbang.org/resource/image/8e/77/8ea5f53e6510d76cf447c23fb15daa77.png" alt="">
这2点证明CloseableHttpClient属于第二种模式即内部带有连接池的API其背后是连接池最佳实践一定是复用。
复用方式很简单你可以把CloseableHttpClient声明为static只创建一次并且在JVM关闭之前通过addShutdownHook钩子关闭连接池在使用的时候直接使用CloseableHttpClient即可无需每次都创建。
首先定义一个right接口来实现服务端接口调用
```
private static CloseableHttpClient httpClient = null;
static {
//当然也可以把CloseableHttpClient定义为Bean然后在@PreDestroy标记的方法内close这个HttpClient
httpClient = HttpClients.custom().setMaxConnPerRoute(1).setMaxConnTotal(1).evictIdleConnections(60, TimeUnit.SECONDS).build();
Runtime.getRuntime().addShutdownHook(new Thread(() -&gt; {
try {
httpClient.close();
} catch (IOException ignored) {
}
}));
}
@GetMapping(&quot;right&quot;)
public String right() {
try (CloseableHttpResponse response = httpClient.execute(new HttpGet(&quot;http://127.0.0.1:45678/httpclientnotreuse/test&quot;))) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
```
然后重新定义一个wrong2接口修复之前按需创建CloseableHttpClient的代码每次用完之后确保连接池可以关闭
```
@GetMapping(&quot;wrong2&quot;)
public String wrong2() {
try (CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(new PoolingHttpClientConnectionManager())
.evictIdleConnections(60, TimeUnit.SECONDS).build();
CloseableHttpResponse response = client.execute(new HttpGet(&quot;http://127.0.0.1:45678/httpclientnotreuse/test&quot;))) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
```
使用wrk对wrong2和right两个接口分别压测60秒可以看到两种使用方式性能上的差异每次创建连接池的QPS是337而复用连接池的QPS是2022
<img src="https://static001.geekbang.org/resource/image/b7/2d/b79fb99cf8a5c3a17e60b0850544472d.png" alt="">
如此大的性能差异显然是因为TCP连接的复用。你可能注意到了刚才定义连接池时我将最大连接数设置为1。所以复用连接池方式复用的始终应该是同一个连接而新建连接池方式应该是每次都会创建新的TCP连接。
接下来我们通过网络抓包工具Wireshark来证实这一点。
如果调用wrong2接口每次创建新的连接池来发起HTTP请求从Wireshark可以看到每次请求服务端45678的客户端端口都是新的。这里我发起了三次请求程序通过HttpClient访问服务端45678的客户端端口号分别是51677、51679和51681
<img src="https://static001.geekbang.org/resource/image/7b/35/7b8f651755cef0c05ecb08727d315e35.png" alt="">
也就是说每次都是新的TCP连接放开HTTP这个过滤条件也可以看到完整的TCP握手、挥手的过程
<img src="https://static001.geekbang.org/resource/image/48/0d/4815c0edd21d5bf0cae8c0c3e578960d.png" alt="">
而复用连接池方式的接口right的表现就完全不同了。可以看到第二次HTTP请求#41的客户端端口61468和第一次连接#23的端口是一样的Wireshark也提示了整个TCP会话中当前#41请求是第二次请求,前一次是#23,后面一次是#75
<img src="https://static001.geekbang.org/resource/image/2c/2c/2cbada9be98ce33321b29d38adb09f2c.png" alt="">
只有TCP连接闲置超过60秒后才会断开连接池会新建连接。你可以尝试通过Wireshark观察这一过程。
接下来,我们就继续聊聊连接池的配置问题。
## 连接池的配置不是一成不变的
为方便根据容量规划设置连接处的属性,连接池提供了许多参数,包括最小(闲置)连接、最大连接、闲置连接生存时间、连接生存时间等。其中,最重要的参数是最大连接数,它决定了连接池能使用的连接数量上限,达到上限后,新来的请求需要等待其他请求释放连接。
但,**最大连接数不是设置得越大越好**。如果设置得太大不仅仅是客户端需要耗费过多的资源维护连接更重要的是由于服务端对应的是多个客户端每一个客户端都保持大量的连接会给服务端带来更大的压力。这个压力又不仅仅是内存压力可以想一下如果服务端的网络模型是一个TCP连接一个线程那么几千个连接意味着几千个线程如此多的线程会造成大量的线程切换开销。
当然,**连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量低下,甚至超时无法获取连接**。
接下来,我们就模拟下压力增大导致数据库连接池打满的情况,来实践下如何确认连接池的使用情况,以及有针对性地进行参数优化。
首先,定义一个用户注册方法,通过@Transactional注解为方法开启事务。其中包含了500毫秒的休眠一个数据库事务对应一个TCP连接所以500多毫秒的时间都会占用数据库连接
```
@Transactional
public User register(){
User user=new User();
user.setName(&quot;new-user-&quot;+System.currentTimeMillis());
userRepository.save(user);
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return user;
}
```
随后修改配置文件启用register-mbeans使Hikari连接池能通过JMX MBean注册连接池相关统计信息方便观察连接池
```
spring.datasource.hikari.register-mbeans=true
```
启动程序并通过JConsole连接进程后可以看到默认情况下最大连接数为10
<img src="https://static001.geekbang.org/resource/image/7b/94/7b8e5aff5a3ef6ade1d8027c20c92f94.png" alt="">
使用wrk对应用进行压测可以看到连接数一下子从0到了10有20个线程在等待获取连接
<img src="https://static001.geekbang.org/resource/image/b2/ef/b22169b8d8bbfabbb8b93ece11a1f9ef.png" alt="">
不久就出现了无法获取数据库连接的异常,如下所示:
```
[15:37:56.156] [http-nio-45678-exec-15] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DataAccessResourceFailureException: unable to obtain isolated JDBC connection; nested exception is org.hibernate.exception.JDBCConnectionException: unable to obtain isolated JDBC connection] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
```
从异常信息中可以看到数据库连接池是HikariPool解决方式很简单修改一下配置文件调整数据库连接池最大连接参数到50即可。
```
spring.datasource.hikari.maximum-pool-size=50
```
然后,再观察一下这个参数是否适合当前压力,满足需求的同时也不占用过多资源。从监控来看这个调整是合理的,有一半的富余资源,再也没有线程需要等待连接了:
<img src="https://static001.geekbang.org/resource/image/d2/31/d24f23f05d49378a10a857cd8b9ef031.png" alt="">
在这个Demo里我知道压测大概能对应使用25左右的并发连接所以直接把连接池最大连接设置为了50。在真实情况下只要数据库可以承受你可以选择在遇到连接超限的时候先设置一个足够大的连接数然后观察最终应用的并发再按照实际并发数留出一半的余量来设置最终的最大连接。
其实,看到错误日志后再调整已经有点儿晚了。更合适的做法是,**对类似数据库连接池的重要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容**。
在这里我是为了演示才通过JConsole查看参数配置后的效果生产上需要把相关数据对接到指标监控体系中持续监测。
**这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、是否合理。之所以要“强调”,是因为这里有坑**
我之前就遇到过这样一个事故。应用准备针对大促活动进行扩容把数据库配置文件中Druid连接池最大连接数maxActive从50提高到了100修改后并没有通过监控验证结果大促当天应用因为连接池连接数不够爆了。
经排查发现当时修改的连接数并没有生效。原因是应用虽然一开始使用的是Druid连接池但后来框架升级了把连接池替换为了Hikari实现原来的那些配置其实都是无效的修改后的参数配置当然也不会生效。
所以说,对连接池进行调参,一定要眼见为实。
## 重点回顾
今天我以三种业务代码最常用的Redis连接池、HTTP连接池、数据库连接池为例和你探讨了有关连接池实现方式、使用姿势和参数配置的三大问题。
客户端SDK实现连接池的方式包括池和连接分离、内部带有连接池和非连接池三种。要正确使用连接池就必须首先鉴别连接池的实现方式。比如Jedis的API实现的是池和连接分离的方式而Apache HttpClient是内置连接池的API。
对于使用姿势其实就是两点,一是确保连接池是复用的,二是尽可能在程序退出之前显式关闭连接池释放资源。连接池设计的初衷就是为了保持一定量的连接,这样连接可以随取随用。从连接池获取连接虽然很快,但连接池的初始化会比较慢,需要做一些管理模块的初始化以及初始最小闲置连接。一旦连接池不是复用的,那么其性能会比随时创建单一连接更差。
最后连接池参数配置中最重要的是最大连接数许多高并发应用往往因为最大连接数不够导致性能问题。但最大连接数不是设置得越大越好够用就好。需要注意的是针对数据库连接池、HTTP连接池、Redis连接池等重要连接池务必建立完善的监控和报警机制根据容量规划及时调整参数配置。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 有了连接池之后获取连接是从连接池获取没有足够连接时连接池会创建连接。这时获取连接操作往往有两个超时时间一个是从连接池获取连接的最长等待时间通常叫作请求连接超时connectRequestTimeout或连接等待超时connectWaitTimeout一个是连接池新建TCP连接三次握手的连接超时通常叫作连接超时connectTimeout。针对JedisPool、Apache HttpClient和Hikari数据库连接池你知道如何设置这2个参数吗
1. 对于带有连接池的SDK的使用姿势最主要的是鉴别其内部是否实现了连接池如果实现了连接池要尽量复用Client。对于NoSQL中的MongoDB来说使用MongoDB Java驱动时MongoClient类应该是每次都创建还是复用呢你能否在[官方文档](https://mongodb.github.io/mongo-java-driver/3.12/)中找到答案呢?
关于连接池,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,558 @@
<audio id="audio" title="05 | HTTP调用你考虑到超时、重试、并发了吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/ad/1ceed88afba883f8aa305d4a6e9e18ad.mp3"></audio>
你好我是朱晔。今天我们一起聊聊进行HTTP调用需要注意的超时、重试、并发等问题。
与执行本地方法不同进行HTTP调用本质上是通过HTTP协议进行一次网络请求。网络请求必然有超时的可能性因此我们必须考虑到这三点
- 首先,框架设置的默认超时是否合理;
- 其次,考虑到网络的不稳定,超时后的请求重试是一个不错的选择,但需要考虑服务端接口的幂等性设计是否允许我们重试;
- 最后需要考虑框架是否会像浏览器那样限制并发连接数以免在服务并发很大的情况下HTTP调用的并发数限制成为瓶颈。
Spring Cloud是Java微服务架构的代表性框架。如果使用Spring Cloud进行微服务开发就会使用Feign进行声明式的服务调用。如果不使用Spring Cloud而直接使用Spring Boot进行微服务开发的话可能会直接使用Java中最常用的HTTP客户端Apache HttpClient进行服务调用。
接下来我们就看看使用Feign和Apache HttpClient进行HTTP接口调用时可能会遇到的超时、重试和并发方面的坑。
## 配置连接超时和读取超时参数的学问
对于HTTP调用虽然应用层走的是HTTP协议但网络层面始终是TCP/IP协议。TCP/IP是面向连接的协议在传输数据之前需要建立连接。几乎所有的网络框架都会提供这么两个超时参数
- 连接超时参数ConnectTimeout让用户配置建连阶段的最长等待时间
- 读取超时参数ReadTimeout用来控制从Socket上读取数据的最长等待时间。
这两个参数看似是网络层偏底层的配置参数,不足以引起开发同学的重视。但,正确理解和配置这两个参数,对业务应用特别重要,毕竟超时不是单方面的事情,需要客户端和服务端对超时有一致的估计,协同配合方能平衡吞吐量和错误率。
**连接超时参数和连接超时的误区有这么两个:**
- **连接超时配置得特别长比如60秒。**一般来说TCP三次握手建立连接需要的时间非常短通常在毫秒级最多到秒级不可能需要十几秒甚至几十秒。如果很久都无法建连很可能是网络或防火墙配置的问题。这种情况下如果几秒连接不上那么可能永远也连接不上。因此设置特别长的连接超时意义不大将其配置得短一些比如1~5秒即可。如果是纯内网调用的话这个参数可以设置得更短在下游服务离线无法连接的时候可以快速失败。
- **排查连接超时问题,却没理清连的是哪里。**通常情况下我们的服务会有多个节点如果别的客户端通过客户端负载均衡技术来连接服务端那么客户端和服务端会直接建立连接此时出现连接超时大概率是服务端的问题而如果服务端通过类似Nginx的反向代理来负载均衡客户端连接的其实是Nginx而不是服务端此时出现连接超时应该排查Nginx。
**读取超时参数和读取超时则会有更多的误区,我将其归纳为如下三个。**
**第一个误区:**认为出现了读取超时,服务端的执行就会中断。
我们来简单测试下。定义一个client接口内部通过HttpClient调用服务端接口server客户端读取超时2秒服务端接口执行耗时5秒。
```
@RestController
@RequestMapping(&quot;clientreadtimeout&quot;)
@Slf4j
public class ClientReadTimeoutController {
private String getResponse(String url, int connectTimeout, int readTimeout) throws IOException {
return Request.Get(&quot;http://localhost:45678/clientreadtimeout&quot; + url)
.connectTimeout(connectTimeout)
.socketTimeout(readTimeout)
.execute()
.returnContent()
.asString();
}
@GetMapping(&quot;client&quot;)
public String client() throws IOException {
log.info(&quot;client1 called&quot;);
//服务端5s超时客户端读取超时2秒
return getResponse(&quot;/server?timeout=5000&quot;, 1000, 2000);
}
@GetMapping(&quot;server&quot;)
public void server(@RequestParam(&quot;timeout&quot;) int timeout) throws InterruptedException {
log.info(&quot;server called&quot;);
TimeUnit.MILLISECONDS.sleep(timeout);
log.info(&quot;Done&quot;);
}
}
```
调用client接口后从日志中可以看到客户端2秒后出现了SocketTimeoutException原因是读取超时服务端却丝毫没受影响在3秒后执行完成。
```
[11:35:11.943] [http-nio-45678-exec-1] [INFO ] [.t.c.c.d.ClientReadTimeoutController:29 ] - client1 called
[11:35:12.032] [http-nio-45678-exec-2] [INFO ] [.t.c.c.d.ClientReadTimeoutController:36 ] - server called
[11:35:14.042] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
java.net.SocketTimeoutException: Read timed out
at java.net.SocketInputStream.socketRead0(Native Method)
...
[11:35:17.036] [http-nio-45678-exec-2] [INFO ] [.t.c.c.d.ClientReadTimeoutController:38 ] - Done
```
我们知道类似Tomcat的Web服务器都是把服务端请求提交到线程池处理的只要服务端收到了请求网络层面的超时和断开便不会影响服务端的执行。因此出现读取超时不能随意假设服务端的处理情况需要根据业务状态考虑如何进行后续处理。
**第二个误区:**认为读取超时只是Socket网络层面的概念是数据传输的最长耗时故将其配置得非常短比如100毫秒。
其实,发生了读取超时,网络层面无法区分是服务端没有把数据返回给客户端,还是数据在网络上耗时较久或丢包。
因为TCP是先建立连接后传输数据对于网络情况不是特别糟糕的服务调用通常可以认为出现连接超时是网络问题或服务不在线而出现读取超时是服务处理超时。确切地说读取超时指的是向Socket写入数据后我们等到Socket返回数据的超时时间其中包含的时间或者说绝大部分的时间是服务端处理业务逻辑的时间。
**第三个误区:**认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长。
进行HTTP请求一般是需要获得结果的属于同步调用。如果超时时间很长在等待服务端返回数据的同时客户端线程通常是Tomcat线程也在等待当下游服务出现大量超时的时候程序可能也会受到拖累创建大量线程最终崩溃。
对定时任务或异步任务来说读取超时配置得长些问题不大。但面向用户响应的请求或是微服务短平快的同步接口调用并发量一般较大我们应该设置一个较短的读取超时时间以防止被下游服务拖慢通常不会设置超过30秒的读取超时。
你可能会说如果把读取超时设置为2秒服务端接口需要3秒岂不是永远都拿不到执行结果了的确是这样因此设置读取超时一定要根据实际情况过长可能会让下游抖动影响到自己过短又可能影响成功率。甚至有些时候我们还要根据下游服务的SLA为不同的服务端接口设置不同的客户端读取超时。
## Feign和Ribbon配合使用你知道怎么配置超时吗
刚才我强调了根据自己的需求配置连接超时和读取超时的重要性你是否尝试过为Spring Cloud的Feign配置超时参数呢有没有被网上的各种资料绕晕呢
在我看来为Feign配置超时参数的复杂之处在于Feign自己有两个超时参数它使用的负载均衡组件Ribbon本身还有相关配置。那么这些配置的优先级是怎样的又哪些什么坑呢接下来我们做一些实验吧。
为测试服务端的超时假设有这么一个服务端接口什么都不干只休眠10分钟
```
@PostMapping(&quot;/server&quot;)
public void server() throws InterruptedException {
TimeUnit.MINUTES.sleep(10);
}
```
首先定义一个Feign来调用这个接口
```
@FeignClient(name = &quot;clientsdk&quot;)
public interface Client {
@PostMapping(&quot;/feignandribbon/server&quot;)
void server();
}
```
然后通过Feign Client进行接口调用
```
@GetMapping(&quot;client&quot;)
public void timeout() {
long begin=System.currentTimeMillis();
try{
client.server();
}catch (Exception ex){
log.warn(&quot;执行耗时:{}ms 错误:{}&quot;, System.currentTimeMillis() - begin, ex.getMessage());
}
}
```
在配置文件仅指定服务端地址的情况下:
```
clientsdk.ribbon.listOfServers=localhost:45678
```
得到如下输出:
```
[15:40:16.094] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时1007ms 错误Read timed out executing POST http://clientsdk/feignandribbon/server
```
从这个输出中,我们可以得到**结论一默认情况下Feign的读取超时是1秒如此短的读取超时算是坑点一**。
我们来分析一下源码。打开RibbonClientConfiguration类后会看到DefaultClientConfigImpl被创建出来之后ReadTimeout和ConnectTimeout被设置为1s
```
/**
* Ribbon client default connect timeout.
*/
public static final int DEFAULT_CONNECT_TIMEOUT = 1000;
/**
* Ribbon client default read timeout.
*/
public static final int DEFAULT_READ_TIMEOUT = 1000;
@Bean
@ConditionalOnMissingBean
public IClientConfig ribbonClientConfig() {
DefaultClientConfigImpl config = new DefaultClientConfigImpl();
config.loadProperties(this.name);
config.set(CommonClientConfigKey.ConnectTimeout, DEFAULT_CONNECT_TIMEOUT);
config.set(CommonClientConfigKey.ReadTimeout, DEFAULT_READ_TIMEOUT);
config.set(CommonClientConfigKey.GZipPayload, DEFAULT_GZIP_PAYLOAD);
return config;
}
```
如果要修改Feign客户端默认的两个全局超时时间你可以设置feign.client.config.default.readTimeout和feign.client.config.default.connectTimeout参数
```
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
```
修改配置后重试,得到如下日志:
```
[15:43:39.955] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时3006ms 错误Read timed out executing POST http://clientsdk/feignandribbon/server
```
可见3秒读取超时生效了。注意这里有一个大坑如果你希望只修改读取超时可能会只配置这么一行
```
feign.client.config.default.readTimeout=3000
```
测试一下你就会发现,这样的配置是无法生效的!
**结论二也是坑点二如果要配置Feign的读取超时就必须同时配置连接超时才能生效**
打开FeignClientFactoryBean可以看到只有同时设置ConnectTimeout和ReadTimeoutRequest.Options才会被覆盖
```
if (config.getConnectTimeout() != null &amp;&amp; config.getReadTimeout() != null) {
builder.options(new Request.Options(config.getConnectTimeout(),
config.getReadTimeout()));
}
```
更进一步如果你希望针对单独的Feign Client设置超时时间可以把default替换为Client的name
```
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
feign.client.config.clientsdk.readTimeout=2000
feign.client.config.clientsdk.connectTimeout=2000
```
可以得出**结论三,单独的超时可以覆盖全局超时,这符合预期,不算坑**
```
[15:45:51.708] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时2006ms 错误Read timed out executing POST http://clientsdk/feignandribbon/server
```
**结论四除了可以配置Feign也可以配置Ribbon组件的参数来修改两个超时时间。这里的坑点三是参数首字母要大写和Feign的配置不同**
```
ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000
```
可以通过日志证明参数生效:
```
[15:55:18.019] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时4003ms 错误Read timed out executing POST http://clientsdk/feignandribbon/server
```
最后我们来看看同时配置Feign和Ribbon的参数最终谁会生效如下代码的参数配置
```
clientsdk.ribbon.listOfServers=localhost:45678
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000
```
日志输出证明最终生效的是Feign的超时
```
[16:01:19.972] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时3006ms 错误Read timed out executing POST http://clientsdk/feignandribbon/server
```
**结论五同时配置Feign和Ribbon的超时以Feign为准**。这有点反直觉因为Ribbon更底层所以你会觉得后者的配置会生效但其实不是这样的。
在LoadBalancerFeignClient源码中可以看到如果Request.Options不是默认值就会创建一个FeignOptionsClientConfig代替原来Ribbon的DefaultClientConfigImpl导致Ribbon的配置被Feign覆盖
```
IClientConfig getClientConfig(Request.Options options, String clientName) {
IClientConfig requestConfig;
if (options == DEFAULT_OPTIONS) {
requestConfig = this.clientFactory.getClientConfig(clientName);
}
else {
requestConfig = new FeignOptionsClientConfig(options);
}
return requestConfig;
}
```
但如果这么配置最终生效的还是Ribbon的超时4秒这容易让人产生Ribbon覆盖了Feign的错觉其实这还是因为坑二所致单独配置Feign的读取超时并不能生效
```
clientsdk.ribbon.listOfServers=localhost:45678
feign.client.config.default.readTimeout=3000
feign.client.config.clientsdk.readTimeout=2000
ribbon.ReadTimeout=4000
```
## 你是否知道Ribbon会自动重试请求呢
一些HTTP客户端往往会内置一些重试策略其初衷是好的毕竟因为网络问题导致丢包虽然频繁但持续时间短往往重试下第二次就能成功但一定要小心这种自作主张是否符合我们的预期。
之前遇到过一个短信重复发送的问题,但短信服务的调用方用户服务,反复确认代码里没有重试逻辑。那问题究竟出在哪里了?我们来重现一下这个案例。
首先定义一个Get请求的发送短信接口里面没有任何逻辑休眠2秒模拟耗时
```
@RestController
@RequestMapping(&quot;ribbonretryissueserver&quot;)
@Slf4j
public class RibbonRetryIssueServerController {
@GetMapping(&quot;sms&quot;)
public void sendSmsWrong(@RequestParam(&quot;mobile&quot;) String mobile, @RequestParam(&quot;message&quot;) String message, HttpServletRequest request) throws InterruptedException {
//输出调用参数后休眠2秒
log.info(&quot;{} is called, {}=&gt;{}&quot;, request.getRequestURL().toString(), mobile, message);
TimeUnit.SECONDS.sleep(2);
}
}
```
配置一个Feign供客户端调用
```
@FeignClient(name = &quot;SmsClient&quot;)
public interface SmsClient {
@GetMapping(&quot;/ribbonretryissueserver/sms&quot;)
void sendSmsWrong(@RequestParam(&quot;mobile&quot;) String mobile, @RequestParam(&quot;message&quot;) String message);
}
```
Feign内部有一个Ribbon组件负责客户端负载均衡通过配置文件设置其调用的服务端为两个节点
```
SmsClient.ribbon.listOfServers=localhost:45679,localhost:45678
```
写一个客户端接口通过Feign调用服务端
```
@RestController
@RequestMapping(&quot;ribbonretryissueclient&quot;)
@Slf4j
public class RibbonRetryIssueClientController {
@Autowired
private SmsClient smsClient;
@GetMapping(&quot;wrong&quot;)
public String wrong() {
log.info(&quot;client is called&quot;);
try{
//通过Feign调用发送短信接口
smsClient.sendSmsWrong(&quot;13600000000&quot;, UUID.randomUUID().toString());
} catch (Exception ex) {
//捕获可能出现的网络错误
log.error(&quot;send sms failed : {}&quot;, ex.getMessage());
}
return &quot;done&quot;;
}
}
```
在45678和45679两个端口上分别启动服务端然后访问45678的客户端接口进行测试。因为客户端和服务端控制器在一个应用中所以45678同时扮演了客户端和服务端的角色。
在45678日志中可以看到29秒时客户端收到请求开始调用服务端接口发短信同时服务端收到了请求2秒后注意对比第一条日志和第三条日志客户端输出了读取超时的错误信息
```
[12:49:29.020] [http-nio-45678-exec-4] [INFO ] [c.d.RibbonRetryIssueClientController:23 ] - client is called
[12:49:29.026] [http-nio-45678-exec-5] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45678/ribbonretryissueserver/sms is called, 13600000000=&gt;a2aa1b32-a044-40e9-8950-7f0189582418
[12:49:31.029] [http-nio-45678-exec-4] [ERROR] [c.d.RibbonRetryIssueClientController:27 ] - send sms failed : Read timed out executing GET http://SmsClient/ribbonretryissueserver/sms?mobile=13600000000&amp;message=a2aa1b32-a044-40e9-8950-7f0189582418
```
而在另一个服务端45679的日志中还可以看到一条请求30秒时收到请求也就是客户端接口调用后的1秒
```
[12:49:30.029] [http-nio-45679-exec-2] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45679/ribbonretryissueserver/sms is called, 13600000000=&gt;a2aa1b32-a044-40e9-8950-7f0189582418
```
客户端接口被调用的日志只输出了一次而服务端的日志输出了两次。虽然Feign的默认读取超时时间是1秒但客户端2秒后才出现超时错误。**显然,这说明客户端自作主张进行了一次重试,导致短信重复发送。**
翻看Ribbon的源码可以发现MaxAutoRetriesNextServer参数默认为1也就是Get请求在某个服务端节点出现问题比如读取超时Ribbon会自动重试一次
```
// DefaultClientConfigImpl
public static final int DEFAULT_MAX_AUTO_RETRIES_NEXT_SERVER = 1;
public static final int DEFAULT_MAX_AUTO_RETRIES = 0;
// RibbonLoadBalancedRetryPolicy
public boolean canRetry(LoadBalancedRetryContext context) {
HttpMethod method = context.getRequest().getMethod();
return HttpMethod.GET == method || lbContext.isOkToRetryOnAllOperations();
}
@Override
public boolean canRetrySameServer(LoadBalancedRetryContext context) {
return sameServerCount &lt; lbContext.getRetryHandler().getMaxRetriesOnSameServer()
&amp;&amp; canRetry(context);
}
@Override
public boolean canRetryNextServer(LoadBalancedRetryContext context) {
// this will be called after a failure occurs and we increment the counter
// so we check that the count is less than or equals to too make sure
// we try the next server the right number of times
return nextServerCount &lt;= lbContext.getRetryHandler().getMaxRetriesOnNextServer()
&amp;&amp; canRetry(context);
}
```
解决办法有两个:
- 一是把发短信接口从Get改为Post。其实这里还有一个API设计问题有状态的API接口不应该定义为Get。根据HTTP协议的规范Get请求用于数据查询而Post才是把数据提交到服务端用于修改或新增。选择Get还是Post的依据应该是API的行为而不是参数大小。**这里的一个误区是Get请求的参数包含在Url QueryString中会受浏览器长度限制所以一些同学会选择使用JSON以Post提交大参数使用Get提交小参数。**
- 二是将MaxAutoRetriesNextServer参数配置为0禁用服务调用失败后在下一个服务端节点的自动重试。在配置文件中添加一行即可
```
ribbon.MaxAutoRetriesNextServer=0
```
看到这里,你觉得问题出在用户服务还是短信服务呢?
在我看来双方都有问题。就像之前说的Get请求应该是无状态或者幂等的短信接口可以设计为支持幂等调用的而用户服务的开发同学如果对Ribbon的重试机制有所了解的话或许就能在排查问题上少走些弯路。
## 并发限制了爬虫的抓取能力
除了超时和重试的坑进行HTTP请求调用还有一个常见的问题是并发数的限制导致程序的处理能力上不去。
我之前遇到过一个爬虫项目,整体爬取数据的效率很低,增加线程池数量也无济于事,只能堆更多的机器做分布式的爬虫。现在,我们就来模拟下这个场景,看看问题出在了哪里。
假设要爬取的服务端是这样的一个简单实现休眠1秒返回数字1
```
@GetMapping(&quot;server&quot;)
public int server() throws InterruptedException {
TimeUnit.SECONDS.sleep(1);
return 1;
}
```
爬虫需要多次调用这个接口进行数据抓取为了确保线程池不是并发的瓶颈我们使用一个没有线程上限的newCachedThreadPool作为爬取任务的线程池再次强调除非你非常清楚自己的需求否则一般不要使用没有线程数量上限的线程池然后使用HttpClient实现HTTP请求把请求任务循环提交到线程池处理最后等待所有任务执行完成后输出执行耗时
```
private int sendRequest(int count, Supplier&lt;CloseableHttpClient&gt; client) throws InterruptedException {
//用于计数发送的请求个数
AtomicInteger atomicInteger = new AtomicInteger();
//使用HttpClient从server接口查询数据的任务提交到线程池并行处理
ExecutorService threadPool = Executors.newCachedThreadPool();
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, count).forEach(i -&gt; {
threadPool.execute(() -&gt; {
try (CloseableHttpResponse response = client.get().execute(new HttpGet(&quot;http://127.0.0.1:45678/routelimit/server&quot;))) {
atomicInteger.addAndGet(Integer.parseInt(EntityUtils.toString(response.getEntity())));
} catch (Exception ex) {
ex.printStackTrace();
}
});
});
//等到count个任务全部执行完毕
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
log.info(&quot;发送 {} 次请求,耗时 {} ms&quot;, atomicInteger.get(), System.currentTimeMillis() - begin);
return atomicInteger.get();
}
```
首先使用默认的PoolingHttpClientConnectionManager构造的CloseableHttpClient测试一下爬取10次的耗时
```
static CloseableHttpClient httpClient1;
static {
httpClient1 = HttpClients.custom().setConnectionManager(new PoolingHttpClientConnectionManager()).build();
}
@GetMapping(&quot;wrong&quot;)
public int wrong(@RequestParam(value = &quot;count&quot;, defaultValue = &quot;10&quot;) int count) throws InterruptedException {
return sendRequest(count, () -&gt; httpClient1);
}
```
虽然一个请求需要1秒执行完成但我们的线程池是可以扩张使用任意数量线程的。按道理说10个请求并发处理的时间基本相当于1个请求的处理时间也就是1秒但日志中显示实际耗时5秒
```
[12:48:48.122] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.h.r.RouteLimitController :54 ] - 发送 10 次请求,耗时 5265 ms
```
查看PoolingHttpClientConnectionManager源码可以注意到有两个重要参数
- **defaultMaxPerRoute=2也就是同一个主机/域名的最大并发请求数为2。我们的爬虫需要10个并发显然是默认值太小限制了爬虫的效率。**
- maxTotal=20也就是所有主机整体最大并发为20这也是HttpClient整体的并发度。目前我们请求数是10最大并发是1020不会成为瓶颈。举一个例子使用同一个HttpClient访问10个域名defaultMaxPerRoute设置为10为确保每一个域名都能达到10并发需要把maxTotal设置为100。
```
public PoolingHttpClientConnectionManager(
final HttpClientConnectionOperator httpClientConnectionOperator,
final HttpConnectionFactory&lt;HttpRoute, ManagedHttpClientConnection&gt; connFactory,
final long timeToLive, final TimeUnit timeUnit) {
...
this.pool = new CPool(new InternalConnectionFactory(
this.configData, connFactory), 2, 20, timeToLive, timeUnit);
...
}
public CPool(
final ConnFactory&lt;HttpRoute, ManagedHttpClientConnection&gt; connFactory,
final int defaultMaxPerRoute, final int maxTotal,
final long timeToLive, final TimeUnit timeUnit) {
...
}}
```
HttpClient是Java非常常用的HTTP客户端这个问题经常出现。你可能会问为什么默认值限制得这么小。
其实这不能完全怪HttpClient很多早期的浏览器也限制了同一个域名两个并发请求。对于同一个域名并发连接的限制其实是HTTP 1.1协议要求的,[这里](http://w3.org/Protocols/rfc2616/rfc2616-sec8.html#sec8.1.4)有这么一段话:
```
Clients that use persistent connections SHOULD limit the number of simultaneous connections that they maintain to a given server. A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy. A proxy SHOULD use up to 2*N connections to another server or proxy, where N is the number of simultaneously active users. These guidelines are intended to improve HTTP response times and avoid congestion.
```
HTTP 1.1协议是20年前制定的现在HTTP服务器的能力强很多了所以有些新的浏览器没有完全遵从2并发这个限制放开并发数到了8甚至更大。如果需要通过HTTP客户端发起大量并发请求不管使用什么客户端请务必确认客户端的实现默认的并发度是否满足需求。
既然知道了问题所在我们就尝试声明一个新的HttpClient放开相关限制设置maxPerRoute为50、maxTotal为100然后修改一下刚才的wrong方法使用新的客户端进行测试
```
httpClient2 = HttpClients.custom().setMaxConnPerRoute(10).setMaxConnTotal(20).build();
```
输出如下10次请求在1秒左右执行完成。可以看到因为放开了一个Host 2个并发的默认限制爬虫效率得到了大幅提升
```
[12:58:11.333] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.h.r.RouteLimitController :54 ] - 发送 10 次请求,耗时 1023 ms
```
## 重点回顾
今天我和你分享了HTTP调用最常遇到的超时、重试和并发问题。
连接超时代表建立TCP连接的时间读取超时代表了等待远端返回数据的时间也包括远端程序处理的时间。在解决连接超时问题时我们要搞清楚连的是谁在遇到读取超时问题的时候我们要综合考虑下游服务的服务标准和自己的服务标准设置合适的读取超时时间。此外在使用诸如Spring Cloud Feign等框架时务必确认连接和读取超时参数的配置是否正确生效。
对于重试因为HTTP协议认为Get请求是数据查询操作是无状态的又考虑到网络出现丢包是比较常见的事情有些HTTP客户端或代理服务器会自动重试Get/Head请求。如果你的接口设计不支持幂等需要关闭自动重试。但更好的解决方案是[遵从HTTP协议](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html)的建议来使用合适的HTTP方法。
最后我们看到包括HttpClient在内的HTTP客户端以及浏览器都会限制客户端调用的最大并发数。如果你的客户端有比较大的请求调用并发比如做爬虫或是扮演类似代理的角色又或者是程序本身并发较高如此小的默认值很容易成为吞吐量的瓶颈需要及时调整。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 第一节中我们强调了要注意连接超时和读取超时参数的配置大多数的HTTP客户端也都有这两个参数。有读就有写但为什么我们很少看到“写入超时”的概念呢
1. 除了Ribbon的AutoRetriesNextServer重试机制Nginx也有类似的重试功能。你了解Nginx相关的配置吗
针对HTTP调用你还遇到过什么坑吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,514 @@
<audio id="audio" title="06 | 20%的业务代码的Spring声明式事务可能都没处理正确" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/2e/23c8957e2bcc2e490cc4b8e1ef6b792e.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊业务代码中与数据库事务相关的坑。
Spring针对Java Transaction API (JTA)、JDBC、Hibernate和Java Persistence API (JPA)等事务API实现了一致的编程模型而Spring的声明式事务功能更是提供了极其方便的事务配置方式配合Spring Boot的自动配置大多数Spring Boot项目只需要在方法上标记@Transactional注解,即可一键开启方法的事务性配置。
据我观察,大多数业务开发同学都有事务的概念,也知道如果整体考虑多个数据库操作要么成功要么失败时,需要通过数据库事务来实现多个操作的一致性和原子性。但,在使用上大多仅限于为方法标记@Transactional,不会去关注事务是否有效、出错后事务是否正确回滚,也不会考虑复杂的业务代码中涉及多个子业务逻辑时,怎么正确处理事务。
事务没有被正确处理,一般来说不会过于影响正常流程,也不容易在测试阶段被发现。但当系统越来越复杂、压力越来越大之后,就会带来大量的数据不一致问题,随后就是大量的人工介入查看和修复数据。
所以说一个成熟的业务系统和一个基本可用能完成功能的业务系统在事务处理细节上的差异非常大。要确保事务的配置符合业务功能的需求往往不仅仅是技术问题还涉及产品流程和架构设计的问题。今天这一讲的标题“20%的业务代码的Spring声明式事务可能都没处理正确”中20%这个数字在我看来还是比较保守的。
我今天要分享的内容就是帮助你在技术问题上理清思路避免因为事务处理不当让业务逻辑的实现产生大量偶发Bug。
## 小心Spring的事务可能没有生效
在使用@Transactional注解开启声明式事务时 第一个最容易忽略的问题是,很可能事务并没有生效。
实现下面的Demo需要一些基础类首先定义一个具有ID和姓名属性的UserEntity也就是一个包含两个字段的用户表
```
@Entity
@Data
public class UserEntity {
@Id
@GeneratedValue(strategy = AUTO)
private Long id;
private String name;
public UserEntity() { }
public UserEntity(String name) {
this.name = name;
}
}
```
为了方便理解我使用Spring JPA做数据库访问实现这样一个Repository新增一个根据用户名查询所有数据的方法
```
@Repository
public interface UserRepository extends JpaRepository&lt;UserEntity, Long&gt; {
List&lt;UserEntity&gt; findByName(String name);
}
```
定义一个UserService类负责业务逻辑处理。如果不清楚@Transactional的实现方式,只考虑代码逻辑的话,这段代码看起来没有问题。
定义一个入口方法createUserWrong1来调用另一个私有方法createUserPrivate私有方法上标记了@Transactional注解。当传入的用户名包含test关键字时判断为用户名不合法抛出异常让用户创建操作失败期望事务可以回滚
```
@Service
@Slf4j
public class UserService {
@Autowired
private UserRepository userRepository;
//一个公共方法供Controller调用内部调用事务性的私有方法
public int createUserWrong1(String name) {
try {
this.createUserPrivate(new UserEntity(name));
} catch (Exception ex) {
log.error(&quot;create user failed because {}&quot;, ex.getMessage());
}
return userRepository.findByName(name).size();
}
//标记了@Transactional的private方法
@Transactional
private void createUserPrivate(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains(&quot;test&quot;))
throw new RuntimeException(&quot;invalid username!&quot;);
}
//根据用户名查询用户数
public int getUserCount(String name) {
return userRepository.findByName(name).size();
}
}
```
下面是Controller的实现只是调用一下刚才定义的UserService中的入口方法createUserWrong1。
```
@Autowired
private UserService userService;
@GetMapping(&quot;wrong1&quot;)
public int wrong1(@RequestParam(&quot;name&quot;) String name) {
return userService.createUserWrong1(name);
}
```
调用接口后发现,即便用户名不合法,用户也能创建成功。刷新浏览器,多次发现有十几个的非法用户注册。
这里给出@Transactional生效原则1**除非特殊配置比如使用AspectJ静态织入实现AOP否则只有定义在public方法上的@Transactional才能生效**。原因是Spring默认通过动态代理的方式实现AOP对目标方法进行增强private方法无法代理到Spring自然也无法动态增强事务处理逻辑。
你可能会说修复方式很简单把标记了事务注解的createUserPrivate方法改为public即可。在UserService中再建一个入口方法createUserWrong2来调用这个public方法再次尝试
```
public int createUserWrong2(String name) {
try {
this.createUserPublic(new UserEntity(name));
} catch (Exception ex) {
log.error(&quot;create user failed because {}&quot;, ex.getMessage());
}
return userRepository.findByName(name).size();
}
//标记了@Transactional的public方法
@Transactional
public void createUserPublic(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains(&quot;test&quot;))
throw new RuntimeException(&quot;invalid username!&quot;);
}
```
测试发现调用新的createUserWrong2方法事务同样不生效。这里我给出@Transactional生效原则2**必须通过代理过的类从外部调用目标方法才能生效**。
Spring通过AOP技术对方法进行增强要调用增强过的方法必然是调用代理后的对象。我们尝试修改下UserService的代码注入一个self然后再通过self实例调用标记有@Transactional注解的createUserPublic方法。设置断点可以看到self是由Spring通过CGLIB方式增强过的类
- CGLIB通过继承方式实现代理类private方法在子类不可见自然也就无法进行事务增强
- this指针代表对象自己Spring不可能注入this所以通过this访问方法必然不是代理。
<img src="https://static001.geekbang.org/resource/image/b0/6c/b077c033fa394353309fbb4f8368e46c.png" alt="">
把this改为self后测试发现在Controller中调用createUserRight方法可以验证事务是生效的非法的用户注册操作可以回滚。
虽然在UserService内部注入自己调用自己的createUserPublic可以正确实现事务但更合理的实现方式是让Controller直接调用之前定义的UserService的createUserPublic方法因为注入自己调用自己很奇怪也不符合分层实现的规范
```
@GetMapping(&quot;right2&quot;)
public int right2(@RequestParam(&quot;name&quot;) String name) {
try {
userService.createUserPublic(new UserEntity(name));
} catch (Exception ex) {
log.error(&quot;create user failed because {}&quot;, ex.getMessage());
}
return userService.getUserCount(name);
}
```
我们再通过一张图来回顾下this自调用、通过self调用以及在Controller中调用UserService三种实现的区别
<img src="https://static001.geekbang.org/resource/image/c4/70/c43ea620b0b611ae194f8438506d7570.png" alt="">
通过this自调用没有机会走到Spring的代理类后两种改进方案调用的是Spring注入的UserService通过代理调用才有机会对createUserPublic方法进行动态增强。
这里,我还有一个小技巧,**强烈建议你在开发时打开相关的Debug日志以方便了解Spring事务实现的细节并及时判断事务的执行情况**。
我们的Demo代码使用JPA进行数据库访问可以这么开启Debug日志
```
logging.level.org.springframework.orm.jpa=DEBUG
```
开启日志后我们再比较下在UserService中通过this调用和在Controller中通过注入的UserService Bean调用createUserPublic区别。很明显this调用因为没有走代理事务没有在createUserPublic方法上生效只在Repository的save方法层面生效
```
//在UserService中通过this调用public的createUserPublic
[10:10:19.913] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
//在Controller中通过注入的UserService Bean调用createUserPublic
[10:10:47.750] [http-nio-45678-exec-6] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo1.UserService.createUserPublic]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
```
你可能还会考虑一个问题这种实现在Controller里处理了异常显得有点繁琐还不如直接把createUserWrong2方法加上@Transactional注解然后在Controller中直接调用这个方法。这样一来既能从外部Controller中调用UserService中的方法方法又是public的能够被动态代理AOP增强。
你可以试一下这种方法,但很容易就会踩第二个坑,即因为没有正确处理异常,导致事务即便生效也不一定能回滚。
## 事务即便生效也不一定能回滚
通过AOP实现事务处理可以理解为使用try…catch…来包裹标记了@Transactional注解的方法**当方法出现了异常并且满足一定条件的时候**在catch里面我们可以设置事务回滚没有异常则直接提交事务。
这里的“一定条件”,主要包括两点。
第一,**只有异常传播出了标记了@Transactional注解的方法,事务才能回滚**。在Spring的TransactionAspectSupport里有个 invokeWithinTransaction方法里面就是处理事务的逻辑。可以看到只有捕获到异常才能进行后续事务处理
```
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
```
第二,**默认情况下出现RuntimeException非受检异常或Error的时候Spring才会回滚事务**。
打开Spring的DefaultTransactionAttribute类能看到如下代码块可以发现相关证据通过注释也能看到Spring这么做的原因大概的意思是受检异常一般是业务异常或者说是类似另一种方法的返回值出现这样的异常可能业务还能完成所以不会主动回滚而Error或RuntimeException代表了非预期的结果应该回滚
```
/**
* The default behavior is as with EJB: rollback on unchecked exception
* ({@link RuntimeException}), assuming an unexpected outcome outside of any
* business rules. Additionally, we also attempt to rollback on {@link Error} which
* is clearly an unexpected outcome as well. By contrast, a checked exception is
* considered a business exception and therefore a regular expected outcome of the
* transactional business method, i.e. a kind of alternative return value which
* still allows for regular completion of resource operations.
* &lt;p&gt;This is largely consistent with TransactionTemplate's default behavior,
* except that TransactionTemplate also rolls back on undeclared checked exceptions
* (a corner case). For declarative transactions, we expect checked exceptions to be
* intentionally declared as business exceptions, leading to a commit by default.
* @see org.springframework.transaction.support.TransactionTemplate#execute
*/
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
```
接下来我和你分享2个反例。
重新实现一下UserService中的注册用户操作
- 在createUserWrong1方法中会抛出一个RuntimeException但由于方法内catch了所有异常异常无法从方法传播出去事务自然无法回滚。
- 在createUserWrong2方法中注册用户的同时会有一次otherTask文件读取操作如果文件读取失败我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常但因为otherTask方法抛出的是受检异常createUserWrong2传播出去的也是受检异常事务同样不会回滚。
```
@Service
@Slf4j
public class UserService {
@Autowired
private UserRepository userRepository;
//异常无法传播出方法,导致事务无法回滚
@Transactional
public void createUserWrong1(String name) {
try {
userRepository.save(new UserEntity(name));
throw new RuntimeException(&quot;error&quot;);
} catch (Exception ex) {
log.error(&quot;create user failed&quot;, ex);
}
}
//即使出了受检异常也无法让事务回滚
@Transactional
public void createUserWrong2(String name) throws IOException {
userRepository.save(new UserEntity(name));
otherTask();
}
//因为文件不存在一定会抛出一个IOException
private void otherTask() throws IOException {
Files.readAllLines(Paths.get(&quot;file-that-not-exist&quot;));
}
}
```
Controller中的实现仅仅是调用UserService的createUserWrong1和createUserWrong2方法这里就贴出实现了。这2个方法的实现和调用虽然完全避开了事务不生效的坑但因为异常处理不当导致程序没有如我们期望的文件操作出现异常时回滚事务。
现在我们来看下修复方式以及如何通过日志来验证是否修复成功。针对这2种情况对应的修复方法如下。
第一,如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置让当前事务处于回滚状态:
```
@Transactional
public void createUserRight1(String name) {
try {
userRepository.save(new UserEntity(name));
throw new RuntimeException(&quot;error&quot;);
} catch (Exception ex) {
log.error(&quot;create user failed&quot;, ex);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
```
运行后可以在日志中看到Rolling back字样确认事务回滚了。同时我们还注意到“Transactional code has requested rollback”的提示表明手动请求回滚
```
[22:14:49.352] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :698 ] - Transactional code has requested rollback
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1906719643&lt;open&gt;)]
```
第二在注解中声明期望遇到所有的Exception都回滚事务来突破默认不回滚受检异常的限制
```
@Transactional(rollbackFor = Exception.class)
public void createUserRight2(String name) throws IOException {
userRepository.save(new UserEntity(name));
otherTask();
}
```
运行后,同样可以在日志中看到回滚的提示:
```
[22:10:47.980] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
[22:10:47.981] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1419329213&lt;open&gt;)]
```
在这个例子中我们展现的是一个复杂的业务逻辑其中有数据库操作、IO操作在IO操作出现问题时希望让数据库事务也回滚以确保逻辑的一致性。在有些业务逻辑中可能会包含多次数据库操作我们不一定希望将两次操作作为一个事务来处理这时候就需要仔细考虑事务传播的配置了否则也可能踩坑。
## 请确认事务传播配置是否符合自己的业务逻辑
有这么一个场景:一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影响主流程,即不影响主用户的注册。
接下来我们模拟一个实现类似业务逻辑的UserService
```
@Autowired
private UserRepository userRepository;
@Autowired
private SubUserService subUserService;
@Transactional
public void createUserWrong(UserEntity entity) {
createMainUser(entity);
subUserService.createSubUserWithExceptionWrong(entity);
}
private void createMainUser(UserEntity entity) {
userRepository.save(entity);
log.info(&quot;createMainUser finish&quot;);
}
```
SubUserService的createSubUserWithExceptionWrong实现正如其名因为最后我们抛出了一个运行时异常错误原因是用户状态无效所以子用户的注册肯定是失败的。我们期望子用户的注册作为一个事务单独回滚不影响主用户的注册这样的逻辑可以实现吗
```
@Service
@Slf4j
public class SubUserService {
@Autowired
private UserRepository userRepository;
@Transactional
public void createSubUserWithExceptionWrong(UserEntity entity) {
log.info(&quot;createSubUserWithExceptionWrong start&quot;);
userRepository.save(entity);
throw new RuntimeException(&quot;invalid status&quot;);
}
}
```
我们在Controller里实现一段测试代码调用UserService
```
@GetMapping(&quot;wrong&quot;)
public int wrong(@RequestParam(&quot;name&quot;) String name) {
try {
userService.createUserWrong(new UserEntity(name));
} catch (Exception ex) {
log.error(&quot;createUserWrong failed, reason:{}&quot;, ex.getMessage());
}
return userService.getUserCount(name);
}
```
调用后可以在日志中发现如下信息很明显事务回滚了最后Controller打出了创建子用户抛出的运行时异常
```
[22:50:42.866] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(103972212&lt;open&gt;)]
[22:50:42.869] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(103972212&lt;open&gt;)] after transaction
[22:50:42.869] [http-nio-45678-exec-8] [ERROR] [t.d.TransactionPropagationController:23 ] - createUserWrong failed, reason:invalid status
```
你马上就会意识到,不对呀,因为运行时异常逃出了@Transactional注解标记的createUserWrong方法Spring当然会回滚事务了。如果我们希望主方法不回滚应该把子方法抛出的异常捕获了。
也就是这么改把subUserService.createSubUserWithExceptionWrong包裹上catch这样外层主方法就不会出现异常了
```
@Transactional
public void createUserWrong2(UserEntity entity) {
createMainUser(entity);
try{
subUserService.createSubUserWithExceptionWrong(entity);
} catch (Exception ex) {
// 虽然捕获了异常但是因为没有开启新事务而当前事务因为异常已经被标记为rollback了所以最终还是会回滚。
log.error(&quot;create sub user error:{}&quot;, ex.getMessage());
}
}
```
运行程序后可以看到如下日志:
```
[22:57:21.722] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserWrong2]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[22:57:21.739] [http-nio-45678-exec-3] [INFO ] [t.c.transaction.demo3.SubUserService:19 ] - createSubUserWithExceptionWrong start
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :356 ] - Found thread-bound EntityManager [SessionImpl(1794007607&lt;open&gt;)] for JPA transaction
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :471 ] - Participating in existing transaction
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :843 ] - Participating transaction failed - marking existing transaction as rollback-only
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :580 ] - Setting JPA transaction on EntityManager [SessionImpl(1794007607&lt;open&gt;)] rollback-only
[22:57:21.740] [http-nio-45678-exec-3] [ERROR] [.g.t.c.transaction.demo3.UserService:37 ] - create sub user error:invalid status
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(1794007607&lt;open&gt;)]
[22:57:21.743] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(1794007607&lt;open&gt;)] after transaction
[22:57:21.743] [http-nio-45678-exec-3] [ERROR] [t.d.TransactionPropagationController:33 ] - createUserWrong2 failed, reason:Transaction silently rolled back because it has been marked as rollback-only
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
...
```
需要注意以下几点:
- 如第1行所示对createUserWrong2方法开启了异常处理
- 如第5行所示子方法因为出现了运行时异常标记当前事务为回滚
- 如第7行所示主方法的确捕获了异常打印出了create sub user error字样
- 如第9行所示主方法提交了事务
- 奇怪的是如第11行和12行所示**Controller里出现了一个UnexpectedRollbackException异常描述提示最终这个事务回滚了而且是静默回滚的**。之所以说是静默是因为createUserWrong2方法本身并没有出异常只不过提交后发现子方法已经把当前事务设置为了回滚无法完成提交。
这挺反直觉的。**我们之前说,出了异常事务不一定回滚,这里说的却是不出异常,事务也不一定可以提交**。原因是,主方法注册主用户的逻辑和子方法注册子用户的逻辑是同一个事务,子逻辑标记了事务需要回滚,主逻辑自然也不能提交了。
看到这里修复方式就很明确了想办法让子逻辑在独立事务中运行也就是改一下SubUserService注册子用户的方法为注解加上propagation = Propagation.REQUIRES_NEW来设置REQUIRES_NEW方式的事务传播策略也就是执行到这个方法时需要开启新的事务并挂起当前事务
```
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createSubUserWithExceptionRight(UserEntity entity) {
log.info(&quot;createSubUserWithExceptionRight start&quot;);
userRepository.save(entity);
throw new RuntimeException(&quot;invalid status&quot;);
}
```
主方法没什么变化同样需要捕获异常防止异常漏出去导致主事务回滚重新命名为createUserRight
```
@Transactional
public void createUserRight(UserEntity entity) {
createMainUser(entity);
try{
subUserService.createSubUserWithExceptionRight(entity);
} catch (Exception ex) {
// 捕获异常,防止主方法回滚
log.error(&quot;create sub user error:{}&quot;, ex.getMessage());
}
}
```
改造后,重新运行程序可以看到如下的关键日志:
- 第1行日志提示我们针对createUserRight方法开启了主方法的事务
- 第2行日志提示创建主用户完成
- 第3行日志可以看到主事务挂起了开启了一个新的事务针对createSubUserWithExceptionRight方案也就是我们的创建子用户的逻辑
- 第4行日志提示子方法事务回滚
- 第5行日志提示子方法事务完成继续主方法之前挂起的事务
- 第6行日志提示主方法捕获到了子方法的异常
- 第8行日志提示主方法的事务提交了随后我们在Controller里没看到静默回滚的异常。
```
[23:17:20.935] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserRight]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[23:17:21.079] [http-nio-45678-exec-1] [INFO ] [.g.t.c.transaction.demo3.UserService:55 ] - createMainUser finish
[23:17:21.082] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :420 ] - Suspending current transaction, creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.SubUserService.createSubUserWithExceptionRight]
[23:17:21.153] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
[23:17:21.160] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :1009] - Resuming suspended transaction after completion of inner transaction
[23:17:21.161] [http-nio-45678-exec-1] [ERROR] [.g.t.c.transaction.demo3.UserService:49 ] - create sub user error:invalid status
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(396441411&lt;open&gt;)]
```
运行测试程序看到如下结果getUserCount得到的用户数量为1代表只有一个用户也就是主用户注册完成了符合预期
<img src="https://static001.geekbang.org/resource/image/3b/f8/3bd9c32b5144025f1a2de5b4ec436ff8.png" alt="">
## 重点回顾
今天我针对业务代码中最常见的使用数据库事务的方式即Spring声明式事务与你总结了使用上可能遇到的三类坑包括
第一,因为配置不正确,导致方法上的事务没生效。我们务必确认调用@Transactional注解标记的方法是public的并且是通过Spring注入的Bean进行调用的。
第二因为异常处理不正确导致事务虽然生效但出现异常时没回滚。Spring默认只会对标记@Transactional注解的方法出现了RuntimeException和Error的时候回滚如果我们的方法捕获了异常那么需要通过手动编码处理事务回滚。如果希望Spring针对其他异常也可以回滚那么可以相应配置@Transactional注解的rollbackFor和noRollbackFor属性来覆盖其默认设置
第三,如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是@Transactional注解的Propagation属性
可见,正确配置事务可以提高业务项目的健壮性。但,又因为健壮性问题往往体现在异常情况或一些细节处理上,很难在主流程的运行和测试中发现,导致业务代码的事务处理逻辑往往容易被忽略,因此**我在代码审查环节一直很关注事务是否正确处理**。
如果你无法确认事务是否真正生效是否按照预期的逻辑进行可以尝试打开Spring的部分Debug日志通过事务的运作细节来验证。也建议你在单元测试时尽量覆盖多的异常场景这样在重构时也能及时发现因为方法的调用方式、异常处理逻辑的调整导致的事务失效问题。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 考虑到Demo的简洁文中所有数据访问使用的都是Spring Data JPA。国内大多数互联网业务项目是使用MyBatis进行数据访问的使用MyBatis配合Spring的声明式事务也同样需要注意文中提到的这些点。你可以尝试把今天的Demo改为MyBatis做数据访问实现看看日志中是否可以体现出这些坑。
1. 在第一节中我们提到如果要针对private方法启用事务动态代理方式的AOP不可行需要使用静态织入方式的AOP也就是在编译期间织入事务增强代码可以配置Spring框架使用AspectJ来实现AOP。你能否参阅Spring的文档“[Using @Transactional with AspectJ](https://docs.spring.io/spring/docs/current/spring-framework-reference/data-access.html#transaction-declarative-aspectj)”试试呢注意AspectJ配合lombok使用还可能会踩一些坑。
有关数据库事务,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,388 @@
<audio id="audio" title="07 | 数据库索引:索引并不是万能药" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/34/943daa98fdac471ac0330e533b799934.mp3"></audio>
你好,我是朱晔。今天,我要和你分享的主题是,数据库的索引并不是万能药。
几乎所有的业务项目都会涉及数据存储虽然当前各种NoSQL和文件系统大行其道但MySQL等关系型数据库因为满足ACID、可靠性高、对开发友好等特点仍然最常被用于存储重要数据。在关系型数据库中索引是优化查询性能的重要手段。
为此我经常看到一些同学一遇到查询性能问题就盲目要求运维或DBA给数据表相关字段创建大量索引。显然这种想法是错误的。今天我们就以MySQL为例来深入理解下索引的原理以及相关误区。
## InnoDB是如何存储数据的
MySQL把数据存储和查询操作抽象成了存储引擎不同的存储引擎对数据的存储和读取方式各不相同。MySQL支持多种存储引擎并且可以以表为粒度设置存储引擎。因为支持事务我们最常使用的是InnoDB。为方便理解下面的内容我先和你简单说说InnoDB是如何存储数据的。
虽然数据保存在磁盘中但其处理是在内存中进行的。为了减少磁盘随机读取次数InnoDB采用页而不是行的粒度来保存数据即数据被分成若干页以页为单位保存在磁盘中。InnoDB的页大小一般是16KB。
各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:
<img src="https://static001.geekbang.org/resource/image/13/d1/1302b4a8d877609486c9a9eed2d8d8d1.png" alt="">
页目录通过槽把记录分成不同的小组每个小组有若干条记录。如图所示记录中最前面的小方块中的数字代表的是当前分组的记录条数最小和最大的槽指向2个特殊的伪记录。有了槽之后我们按照主键搜索页中记录时就可以采用二分法快速搜索无需从最小记录开始遍历整个页中的记录链表。
举一个例子如果要搜索主键PK=15的记录
- 先二分得出槽中间位是(0+6)/2=3看到其指向的记录是1215所以需要从#3槽后继续搜索记录
- 再使用二分搜索出#3槽和#6槽的中间位是(3+6)/2=4.5取整4#4槽对应的记录是1615所以记录一定在#4槽中
- 再从#3槽指向的12号记录开始向下搜索3次定位到15号记录。
理解了InnoDB存储数据的原理后我们就可以继续学习MySQL索引相关的原理和坑了。
## 聚簇索引和二级索引
说到索引,页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。
为了解决这个问题InnoDB引入了B+树。如下图所示B+树是一棵倒过来的树:
<img src="https://static001.geekbang.org/resource/image/e7/65/e76adf029e63a045e05956039f81f265.png" alt="">
B+树的特点包括:
- 最底层的节点叫作叶子节点,用来存放数据;
- 其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引;
- 非叶子节点分为不同层次,通过分层来降低每一层的搜索量;
- 所有节点按照索引键大小排序,构成一个双向链表,加速范围查找。
因此InnoDB使用B+树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索引。如果把上图叶子节点下面方块中的省略号看作实际数据的话,那么它就是聚簇索引的示意图。**由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个**。
InnoDB会自动使用主键唯一定义一条记录的单个或多个字段作为聚簇索引的索引键如果没有主键就选择第一个不包含NULL值的唯一列。上图方框中的数字代表了索引键的值对聚簇索引而言一般就是主键。
我们再看看B+树如何实现快速查找主键。比如我们要搜索PK=4的数据通过根节点中的索引可以知道数据在第一个记录指向的2号页中通过2号页的索引又可以知道数据在5号页5号页就是实际的数据页然后再通过二分法查找页目录马上可以找到记录的指针。
为了实现非主键字段的快速搜索就引出了二级索引也叫作非聚簇索引、辅助索引。二级索引也是利用的B+树的数据结构,如下图所示:
<img src="https://static001.geekbang.org/resource/image/4b/3a/4be8f22d993bd92878209d00a1264b3a.png" alt="">
这次二级索引的叶子节点中保存的不是实际数据,而是主键,获得主键值后去聚簇索引中获得数据行。这个过程就叫作回表。
举个例子有个索引是针对用户名字段创建的索引记录上面方块中的字母是用户名按照顺序形成链表。如果我们要搜索用户名为b的数据经过两次定位可以得出在#5数据页中查出所有的主键为7和6再拿着这两个主键继续使用聚簇索引进行两次回表得到完整数据。
## 考虑额外创建二级索引的代价
创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面。接下来,我就与你仔细分析下吧。
**首先是维护代价**。创建N个二级索引就需要再创建N棵B+树新增数据时不仅要修改聚簇索引还需要修改这N个二级索引。
我们通过实验测试一下创建索引的代价。假设有一个person表有主键ID以及name、score、create_time三个字段
```
CREATE TABLE `person` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`score` int(11) NOT NULL,
`create_time` timestamp NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
通过下面的存储过程循环创建10万条测试数据我的机器的耗时是140秒本文的例子均在MySQL 5.7.26中执行):
```
CREATE DEFINER=`root`@`%` PROCEDURE `insert_person`()
begin
declare c_id integer default 1;
while c_id&lt;=100000 do
insert into person values(c_id, concat('name',c_id), c_id+100, date_sub(NOW(), interval c_id second));
set c_id=c_id+1;
end while;
end
```
如果再创建两个索引一个是name和score构成的联合索引另一个是单一列create_time的索引那么创建10万条记录的耗时提高到154秒
```
KEY `name_score` (`name`,`score`) USING BTREE,
KEY `create_time` (`create_time`) USING BTREE
```
这里我再额外提一下页中的记录都是按照索引值从小到大的顺序存放的新增记录就需要往页中插入数据现有的页满了就需要新创建一个页把现有页的部分数据移过去这就是页分裂如果删除了许多数据使得页比较空闲还需要进行页合并。页分裂和合并都会有IO代价并且可能在操作过程中产生死锁。
你可以查看[这个文档](https://dev.mysql.com/doc/refman/5.7/en/index-page-merge-threshold.html),以进一步了解如何设置合理的合并阈值,来平衡页的空闲率和因为再次页分裂产生的代价。
**其次是空间代价**。虽然二级索引不保存原始数据但要保存索引列的数据所以会占用更多的空间。比如person表创建了两个索引后使用下面的SQL查看数据和索引占用的磁盘
```
SELECT DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_NAME='person'
```
结果显示数据本身只占用了4.7M而索引占用了8.4M。
**最后是回表的代价**。二级索引不保存原始数据通过索引找到主键后需要再查询聚簇索引才能得到我们要的数据。比如使用SELECT * 按照name字段查询用户使用EXPLAIN查看执行计划
```
EXPLAIN SELECT * FROM person WHERE NAME='name1'
```
执行计划如下,可以发现:
<img src="https://static001.geekbang.org/resource/image/f3/21/f380ee99efb997a8520d16f5433f7e21.png" alt="">
- key字段代表实际走的是哪个索引其值是name_score说明走的是name_score这个索引。
- type字段代表了访问表的方式其值ref说明是二级索引等值匹配符合我们的查询。
把SQL中的*修改为NAME和SCORE也就是SELECT name_score联合索引包含的两列
```
EXPLAIN SELECT NAME,SCORE FROM person WHERE NAME='name1'
```
再来看看执行计划:
<img src="https://static001.geekbang.org/resource/image/88/e7/88809b6f547238596d141eab27f3d7e7.png" alt="">
可以看到Extra列多了一行Using index的提示证明这次查询直接查的是二级索引免去了回表。
原因很简单联合索引中其实保存了多个索引列的值对于页中的记录先按照字段1排序如果相同再按照字段2排序如图所示
<img src="https://static001.geekbang.org/resource/image/80/72/803c3e6a8df1d6031db70473dc948472.png" alt="">
图中,叶子节点每一条记录的第一和第二个方块是索引列的数据,第三个方块是记录的主键。如果我们需要查询的是索引列索引或联合索引能覆盖的数据,那么查询索引本身已经“覆盖”了需要的数据,不再需要回表查询。因此,这种情况也叫作**索引覆盖**。我会在最后一小节介绍如何查看不同查询的成本,和你一起看看索引覆盖和索引查询后回表的代价差异。
最后,我和你总结下关于索引开销的最佳实践吧。
第一无需一开始就建立索引可以等到业务场景明确后或者是数据量超过1万、查询变慢后再针对需要查询、排序或分组的字段创建索引。创建索引后可以使用EXPLAIN命令确认查询是否可以使用索引。我会在下一小节展开说明。
第二尽量索引轻量级的字段比如能索引int字段就不要索引varchar字段。索引字段也可以是部分前缀在创建的时候指定字段索引长度。针对长文本的搜索可以考虑使用Elasticsearch等专门用于文本搜索的索引数据库。
第三尽量不要在SQL语句中SELECT *而是SELECT必要的字段甚至可以考虑使用联合索引来包含我们要搜索的字段既能实现索引加速又可以避免回表的开销。
## 不是所有针对索引列的查询都能用上索引
在上一个案例中我创建了一个name+score的联合索引仅搜索name时就能够用上这个联合索引。这就引出两个问题
- 是不是建了索引一定可以用上?
- 怎么选择创建联合索引还是多个独立索引?
首先,我们通过几个案例来分析一下索引失效的情况。
第一,**索引只能匹配列前缀**。比如下面的LIKE语句搜索name后缀为name123的用户无法走索引执行计划的type=ALL代表了全表扫描
```
EXPLAIN SELECT * FROM person WHERE NAME LIKE '%name123' LIMIT 100
```
<img src="https://static001.geekbang.org/resource/image/e1/c9/e1033c6534938f8381fce051fb8ef8c9.png" alt="">
把百分号放到后面走前缀匹配type=range表示走索引扫描key=name_score看到实际走了name_score索引
```
EXPLAIN SELECT * FROM person WHERE NAME LIKE 'name123%' LIMIT 100
```
<img src="https://static001.geekbang.org/resource/image/95/5a/95074c69e68039738046fd4275c4d85a.png" alt="">
原因很简单索引B+树中行数据按照索引值排序,只能根据前缀进行比较。如果要按照后缀搜索也希望走索引的话,并且永远只是按照后缀搜索的话,可以把数据反过来存,用的时候再倒过来。
第二,**条件涉及函数操作无法走索引**。比如搜索条件用到了LENGTH函数肯定无法走索引
```
EXPLAIN SELECT * FROM person WHERE LENGTH(NAME)=7
```
<img src="https://static001.geekbang.org/resource/image/f1/08/f1eadcdd35b96c9f982115e528ee6808.png" alt="">
同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。如果需要针对函数调用走数据库索引的话,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。
第三,**联合索引只能匹配左边的列**。也就是说虽然对name和score建了联合索引但是仅按照score列搜索无法走索引
```
EXPLAIN SELECT * FROM person WHERE SCORE&gt;45678
```
<img src="https://static001.geekbang.org/resource/image/0d/e3/0d3d1a4ad0ae545f0264be3de781e0e3.png" alt="">
原因也很简单在联合索引的情况下数据是按照索引第一列排序第一列数据相同时才会按照第二列排序。也就是说如果我们想使用联合索引中尽可能多的列查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索肯定无法走索引。尝试把搜索条件加入name列可以看到走了name_score索引
```
EXPLAIN SELECT * FROM person WHERE SCORE&gt;45678 AND NAME LIKE 'NAME45%'
```
<img src="https://static001.geekbang.org/resource/image/77/17/77c946fcf49059d40673cf6075119d17.png" alt="">
需要注意的是因为有查询优化器所以name作为WHERE子句的第几个条件并不是很重要。
现在回到最开始的两个问题。
- 是不是建了索引一定可以用上并不是只有当查询能符合索引存储的实际结构时才能用上。这里我只给出了三个肯定用不上索引的反例。其实有的时候即使可以走索引MySQL也不一定会选择使用索引。我会在下一小节展开这一点。
- 怎么选择建联合索引还是多个独立索引?如果你的搜索条件经常会使用多个字段进行搜索,那么可以考虑针对这几个字段建联合索引;同时,针对多字段建立联合索引,使用索引覆盖的可能更大。如果只会查询单个字段,可以考虑建单独的索引,毕竟联合索引保存了不必要字段也有成本。
## 数据库基于成本决定是否走索引
通过前面的案例我们可以看到查询数据可以直接在聚簇索引上进行全表扫描也可以走二级索引扫描后到聚簇索引回表。看到这里你不禁要问了MySQL到底是怎么确定走哪种方案的呢。
其实MySQL在查询数据之前会先对可能的方案做执行计划然后依据成本决定走哪个执行计划。
这里的成本包括IO成本和CPU成本
- IO成本是从磁盘把数据加载到内存的成本。默认情况下读取数据页的IO成本常数是1也就是读取1个页成本是1
- CPU成本是检测数据是否满足条件和排序等CPU操作的成本。默认情况下检测记录的成本是0.2。
基于此,我们分析下全表扫描的成本。
全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录加入结果集的过程。那么,要计算全表扫描的代价需要两个信息:
- 聚簇索引占用的页面数用来计算读取数据的IO成本
- 表中的记录数用来计算搜索的CPU成本。
那么MySQL是实时统计这些信息的吗其实并不是MySQL维护了表的统计信息可以使用下面的命令查看
```
SHOW TABLE STATUS LIKE 'person'
```
输出如下:
<img src="https://static001.geekbang.org/resource/image/55/32/5554af3acf1005fac1b6a494b6578732.png" alt="">
可以看到:
- 总行数是100086行之前EXPLAIN时也看到rows为100086。你可能说person表不是有10万行记录吗为什么这里多了86行其实MySQL的统计信息是一个估算其统计方式比较复杂我就不再展开了。但不妨碍我们根据这个值估算CPU成本是100086*0.2=20017左右。
- 数据长度是4734976字节。对于InnoDB来说这就是聚簇索引占用的空间等于聚簇索引的页面数量*每个页面的大小。InnoDB每个页面的大小是16KB大概计算出页面数量是289因此IO成本是289左右。
所以全表扫描的总成本是20306左右。
接下来我还是用person表这个例子和你分析下MySQL如何基于成本来制定执行计划。现在我要用下面的SQL查询name&gt;name84059 AND create_time&gt;2020-01-24 05:00:00
```
EXPLAIN SELECT * FROM person WHERE NAME &gt;'name84059' AND create_time&gt;'2020-01-24 05:00:00'
```
其执行计划是全表扫描:
<img src="https://static001.geekbang.org/resource/image/54/a8/54c6e60d390b54d5e1ae1e8bc2451fa8.png" alt="">
只要把create_time条件中的5点改为6点就变为走索引了并且走的是create_time索引而不是name_score联合索引
<img src="https://static001.geekbang.org/resource/image/25/aa/2501093bce47944d4a9c2b090c2f8baa.png" alt="">
**我们可以得到两个结论:**
- MySQL选择索引并不是按照WHERE条件中列的顺序进行的
- 即便列有索引甚至有多个可能的索引方案MySQL也可能不走索引。
其原因就是MySQL并不是猜拳决定是否走索引的而是根据成本来判断的。虽然表的统计信息不完全准确但足够用于策略的判断了。
不过有时会因为统计信息的不准确或成本估算的问题实际开销会和MySQL统计出来的差距较大导致MySQL选择错误的索引或是直接选择走全表扫描这个时候就需要人工干预使用强制索引了。比如像这样强制走name_score索引
```
EXPLAIN SELECT * FROM person FORCE INDEX(name_score) WHERE NAME &gt;'name84059' AND create_time&gt;'2020-01-24 05:00:00'
```
我们介绍了MySQL会根据成本选择执行计划也通过EXPLAIN知道了优化器最终会选择怎样的执行计划但MySQL如何制定执行计划始终是一个黑盒。那么有没有什么办法可以了解各种执行计划的成本以及MySQL做出选择的依据呢
在MySQL 5.6及之后的版本中我们可以使用optimizer trace功能查看优化器生成执行计划的整个过程。有了这个功能我们不仅可以了解优化器的选择过程更可以了解每一个执行环节的成本然后依靠这些信息进一步优化查询。
如下代码所示打开optimizer_trace后再执行SQL就可以查询information_schema.OPTIMIZER_TRACE表查看执行计划了最后可以关闭optimizer_trace功能
```
SET optimizer_trace=&quot;enabled=on&quot;;
SELECT * FROM person WHERE NAME &gt;'name84059' AND create_time&gt;'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace=&quot;enabled=off&quot;;
```
对于按照create_time&gt;'2020-01-24 05:00:00条件走全表扫描的SQL我从OPTIMIZER_TRACE的执行结果中摘出了几个重要片段来重点分析
- 使用name_score对name84059&lt;name条件进行索引扫描需要扫描25362行成本是30435因此最终没有选择这个方案。这里的30435是查询二级索引的IO成本和CPU成本之和再加上回表查询聚簇索引的IO成本和CPU成本之和我就不再具体分析了
```
{
&quot;index&quot;: &quot;name_score&quot;,
&quot;ranges&quot;: [
&quot;name84059 &lt; name&quot;
],
&quot;rows&quot;: 25362,
&quot;cost&quot;: 30435,
&quot;chosen&quot;: false,
&quot;cause&quot;: &quot;cost&quot;
},
```
- 使用create_time进行索引扫描需要扫描23758行成本是28511同样因为成本原因没有选择这个方案
```
{
&quot;index&quot;: &quot;create_time&quot;,
&quot;ranges&quot;: [
&quot;0x5e2a79d0 &lt; create_time&quot;
],
&quot;rows&quot;: 23758,
&quot;cost&quot;: 28511,
&quot;chosen&quot;: false,
&quot;cause&quot;: &quot;cost&quot;
}
```
- 最终选择了全表扫描方式作为执行计划。可以看到全表扫描100086条记录的成本是20306和我们之前计算的一致显然是小于其他两个方案的28511和30435
```
{
&quot;considered_execution_plans&quot;: [{
&quot;table&quot;: &quot;`person`&quot;,
&quot;best_access_path&quot;: {
&quot;considered_access_paths&quot;: [{
&quot;rows_to_scan&quot;: 100086,
&quot;access_type&quot;: &quot;scan&quot;,
&quot;resulting_rows&quot;: 100086,
&quot;cost&quot;: 20306,
&quot;chosen&quot;: true
}]
},
&quot;rows_for_plan&quot;: 100086,
&quot;cost_for_plan&quot;: 20306,
&quot;chosen&quot;: true
}]
},
```
把SQL中的create_time条件从05:00改为06:00再次分析OPTIMIZER_TRACE可以看到这次执行计划选择的是走create_time索引。因为是查询更晚时间的数据走create_time索引需要扫描的行数从23758减少到了16588。这次走这个索引的成本19907小于全表扫描的20306更小于走name_score索引的30435
```
{
&quot;index&quot;: &quot;create_time&quot;,
&quot;ranges&quot;: [
&quot;0x5e2a87e0 &lt; create_time&quot;
],
&quot;rows&quot;: 16588,
&quot;cost&quot;: 19907,
&quot;chosen&quot;: true
}
```
有关optimizer trace的更多信息你可以参考[MySQL的文档](https://dev.mysql.com/doc/internals/en/optimizer-tracing.html)。
## 重点回顾
今天我先和你分析了MySQL InnoDB存储引擎页、聚簇索引和二级索引的结构然后分析了关于索引的两个误区。
第一个误区是,考虑到索引的维护代价、空间占用和查询时回表的代价,不能认为索引越多越好。索引一定是按需创建的,并且要尽可能确保足够轻量。一旦创建了多字段的联合索引,我们要考虑尽可能利用索引本身完成数据查询,减少回表的成本。
第二个误区是不能认为建了索引就一定有效对于后缀的匹配查询、查询中不包含联合索引的第一列、查询条件涉及函数计算等情况无法使用索引。此外即使SQL本身符合索引的使用条件MySQL也会通过评估各种查询方式的代价来决定是否走索引以及走哪个索引。
因此在尝试通过索引进行SQL性能优化的时候务必通过执行计划或实际的效果来确认索引是否能有效改善性能问题否则增加了索引不但没解决性能问题还增加了数据库增删改的负担。如果对EXPLAIN给出的执行计划有疑问的话你还可以利用optimizer_trace查看详细的执行计划做进一步分析。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在介绍二级索引代价时我们通过EXPLAIN命令看到了索引覆盖和回表的两种情况。你能用optimizer trace来分析一下这两种情况的成本差异吗
1. 索引除了可以用于加速搜索外还可以在排序时发挥作用你能通过EXPLAIN来证明吗你知道在什么情况下针对排序索引会失效吗
针对数据库索引,你还有什么心得吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,685 @@
<audio id="audio" title="08 | 判等问题:程序里如何确定你就是你?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/20/f04eaa79bec7e05e5f26e85a22403e20.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊程序里的判等问题。
你可能会说判等不就是一行代码的事情吗有什么好说的。但这一行代码如果处理不当不仅会出现Bug还可能会引起内存泄露等问题。涉及判等的Bug即使是使用==这种错误的判等方式,也不是所有时候都会出问题。所以类似的判等问题不太容易发现,可能会被隐藏很久。
今天我就equals、compareTo和Java的数值缓存、字符串驻留等问题展开讨论希望你可以理解其原理彻底消除业务代码中的相关Bug。
## 注意equals和==的区别
在业务代码中我们通常使用equals或== 进行判等操作。equals是方法而==是操作符,它们的使用是有区别的:
- 对基本类型比如int、long进行判等只能使用==,比较的是直接值。因为基本类型的值就是其数值。
- 对引用类型比如Integer、Long和String进行判等需要使用equals进行内容判等。因为引用类型的直接值是指针使用==的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。
这就引出了我们必须必须要知道的第一个结论:**比较值的内容,除了基本类型只能使用==外其他类型都需要使用equals**。
在开篇我提到了,即使使用==对Integer或String进行判等有些时候也能得到正确结果。这又是为什么呢
我们用下面的测试用例深入研究下:
- 使用==对两个值为127的直接赋值的Integer对象判等
- 使用==对两个值为128的直接赋值的Integer对象判等
- 使用==对一个值为127的直接赋值的Integer和另一个通过new Integer声明的值为127的对象判等
- 使用==对两个通过new Integer声明的值为127的对象判等
- 使用==对一个值为128的直接赋值的Integer对象和另一个值为128的int基本类型判等。
```
Integer a = 127; //Integer.valueOf(127)
Integer b = 127; //Integer.valueOf(127)
log.info(&quot;\nInteger a = 127;\n&quot; +
&quot;Integer b = 127;\n&quot; +
&quot;a == b ? {}&quot;,a == b); // true
Integer c = 128; //Integer.valueOf(128)
Integer d = 128; //Integer.valueOf(128)
log.info(&quot;\nInteger c = 128;\n&quot; +
&quot;Integer d = 128;\n&quot; +
&quot;c == d ? {}&quot;, c == d); //false
Integer e = 127; //Integer.valueOf(127)
Integer f = new Integer(127); //new instance
log.info(&quot;\nInteger e = 127;\n&quot; +
&quot;Integer f = new Integer(127);\n&quot; +
&quot;e == f ? {}&quot;, e == f); //false
Integer g = new Integer(127); //new instance
Integer h = new Integer(127); //new instance
log.info(&quot;\nInteger g = new Integer(127);\n&quot; +
&quot;Integer h = new Integer(127);\n&quot; +
&quot;g == h ? {}&quot;, g == h); //false
Integer i = 128; //unbox
int j = 128;
log.info(&quot;\nInteger i = 128;\n&quot; +
&quot;int j = 128;\n&quot; +
&quot;i == j ? {}&quot;, i == j); //true
```
通过运行结果可以看到虽然看起来永远是在对127和127、128和128判等但==却没有永远给我们true的答复。原因是什么呢
第一个案例中编译器会把Integer a = 127转换为Integer.valueOf(127)。查看源码可以发现,这个**转换在内部其实做了缓存使得两个Integer指向同一个对象**,所以==返回true。
```
public static Integer valueOf(int i) {
if (i &gt;= IntegerCache.low &amp;&amp; i &lt;= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
```
第二个案例中之所以同样的代码128就返回false的原因是默认情况下会缓存[-128, 127]的数值而128处于这个区间之外。设置JVM参数加上-XX:AutoBoxCacheMax=1000再试试是不是就返回true了呢
```
private static class IntegerCache {
static final int low = -128;
static final int high;
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty(&quot;java.lang.Integer.IntegerCache.high&quot;);
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k &lt; cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high &gt;= 127;
}
}
```
第三和第四个案例中New出来的Integer始终是不走缓存的新对象。比较两个新对象或者比较一个新对象和一个来自缓存的对象结果肯定不是相同的对象因此返回false。
第五个案例中我们把装箱的Integer和基本类型int比较前者会先拆箱再比较比较的肯定是数值而不是引用因此返回true。
看到这里对于Integer什么时候是相同对象什么时候是不同对象就很清楚了吧。但知道这些其实意义不大因为在大多数时候我们并不关心Integer对象是否是同一个**只需要记得比较Integer的值请使用equals而不是==**对于基本类型int的比较当然只能使用==)。
其实,我们应该都知道这个原则,只是有的时候特别容易忽略。以我之前遇到过的一个生产事故为例,有这么一个枚举定义了订单状态和对于状态的描述:
```
enum StatusEnum {
CREATED(1000, &quot;已创建&quot;),
PAID(1001, &quot;已支付&quot;),
DELIVERED(1002, &quot;已送到&quot;),
FINISHED(1003, &quot;已完成&quot;);
private final Integer status; //注意这里的Integer
private final String desc;
StatusEnum(Integer status, String desc) {
this.status = status;
this.desc = desc;
}
}
```
在业务代码中,开发同学使用了==对枚举和入参OrderQuery中的status属性进行判等
```
@Data
public class OrderQuery {
private Integer status;
private String name;
}
@PostMapping(&quot;enumcompare&quot;)
public void enumcompare(@RequestBody OrderQuery orderQuery){
StatusEnum statusEnum = StatusEnum.DELIVERED;
log.info(&quot;orderQuery:{} statusEnum:{} result:{}&quot;, orderQuery, statusEnum, statusEnum.status == orderQuery.getStatus());
}
```
**因为枚举和入参OrderQuery中的status都是包装类型所以通过==判等肯定是有问题的。只是这个问题比较隐晦**,究其原因在于:
- 只看枚举的定义CREATED(1000, “已创建”)容易让人误解status值是基本类型
- 因为有Integer缓存机制的存在所以使用==判等并不是所有情况下都有问题。在这次事故中订单状态的值从100开始增长程序一开始不出问题直到订单状态超过127后才出现Bug。
在了解清楚为什么Integer使用==判等有时候也有效的原因之后我们再来看看为什么String也有这个问题。我们使用几个用例来测试下
- 对两个直接声明的值都为1的String使用==判等;
- 对两个new出来的值都为2的String使用==判等;
- 对两个new出来的值都为3的String先进行intern操作再使用==判等;
- 对两个new出来的值都为4的String通过equals判等。
```
String a = &quot;1&quot;;
String b = &quot;1&quot;;
log.info(&quot;\nString a = \&quot;1\&quot;;\n&quot; +
&quot;String b = \&quot;1\&quot;;\n&quot; +
&quot;a == b ? {}&quot;, a == b); //true
String c = new String(&quot;2&quot;);
String d = new String(&quot;2&quot;);
log.info(&quot;\nString c = new String(\&quot;2\&quot;);\n&quot; +
&quot;String d = new String(\&quot;2\&quot;);&quot; +
&quot;c == d ? {}&quot;, c == d); //false
String e = new String(&quot;3&quot;).intern();
String f = new String(&quot;3&quot;).intern();
log.info(&quot;\nString e = new String(\&quot;3\&quot;).intern();\n&quot; +
&quot;String f = new String(\&quot;3\&quot;).intern();\n&quot; +
&quot;e == f ? {}&quot;, e == f); //true
String g = new String(&quot;4&quot;);
String h = new String(&quot;4&quot;);
log.info(&quot;\nString g = new String(\&quot;4\&quot;);\n&quot; +
&quot;String h = new String(\&quot;4\&quot;);\n&quot; +
&quot;g == h ? {}&quot;, g.equals(h)); //true
```
在分析这个结果之前我先和你说说Java的字符串常量池机制。首先要明确的是其设计初衷是节省内存。当代码中出现双引号形式创建字符串对象时JVM会先对这个字符串进行检查如果字符串常量池中存在相同内容的字符串对象的引用则将这个引用返回否则创建新的字符串对象然后将这个引用放入字符串常量池并返回该引用。这种机制就是字符串驻留或池化。
再回到刚才的例子,再来分析一下运行结果:
- 第一个案例返回true因为Java的字符串驻留机制直接使用双引号声明出来的两个String对象指向常量池中的相同字符串。
- 第二个案例new出来的两个String是不同对象引用当然不同所以得到false的结果。
- 第三个案例使用String提供的intern方法也会走常量池机制所以同样能得到true。
- 第四个案例通过equals对值内容判等是正确的处理方式当然会得到true。
**虽然使用new声明的字符串调用intern方法也可以让字符串进行驻留但在业务代码中滥用intern可能会产生性能问题**
写代码测试一下通过循环把1到1000万之间的数字以字符串形式intern后存入一个List
```
List&lt;String&gt; list = new ArrayList&lt;&gt;();
@GetMapping(&quot;internperformance&quot;)
public int internperformance(@RequestParam(value = &quot;size&quot;, defaultValue = &quot;10000000&quot;)int size) {
//-XX:+PrintStringTableStatistics
//-XX:StringTableSize=10000000
long begin = System.currentTimeMillis();
list = IntStream.rangeClosed(1, size)
.mapToObj(i-&gt; String.valueOf(i).intern())
.collect(Collectors.toList());
log.info(&quot;size:{} took:{}&quot;, size, System.currentTimeMillis() - begin);
return list.size();
}
```
在启动程序时设置JVM参数-XX:+PrintStringTableStatistic程序退出时可以打印出字符串常量表的统计信息。调用接口后关闭程序输出如下
```
[11:01:57.770] [http-nio-45678-exec-2] [INFO ] [.t.c.e.d.IntAndStringEqualController:54 ] - size:10000000 took:44907
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 10030230 = 240725520 bytes, avg 24.000
Number of literals : 10030230 = 563005568 bytes, avg 56.131
Total footprint : = 804211192 bytes
Average bucket size : 167.134
Variance of bucket size : 55.808
Std. dev. of bucket size: 7.471
Maximum bucket size : 198
```
可以看到1000万次intern操作耗时居然超过了44秒。
其实原因在于字符串常量池是一个固定容量的Map。如果容量太小Number of buckets=60013、字符串太多1000万个字符串那么每一个桶中的字符串数量会非常多所以搜索起来就很慢。输出结果中的Average bucket size=167代表了Map中桶的平均长度是167。
解决方式是设置JVM参数-XX:StringTableSize指定更多的桶。设置-XX:StringTableSize=10000000后重启应用
```
[11:09:04.475] [http-nio-45678-exec-1] [INFO ] [.t.c.e.d.IntAndStringEqualController:54 ] - size:10000000 took:5557
StringTable statistics:
Number of buckets : 10000000 = 80000000 bytes, avg 8.000
Number of entries : 10030156 = 240723744 bytes, avg 24.000
Number of literals : 10030156 = 562999472 bytes, avg 56.131
Total footprint : = 883723216 bytes
Average bucket size : 1.003
Variance of bucket size : 1.587
Std. dev. of bucket size: 1.260
Maximum bucket size : 10
```
可以看到1000万次调用耗时只有5.5秒Average bucket size降到了1效果明显。
好了,是时候给出第二原则了:**没事别轻易用intern如果要用一定要注意控制驻留的字符串的数量并留意常量表的各项指标**。
## 实现一个equals没有这么简单
如果看过Object类源码你可能就知道equals的实现其实是比较对象引用
```
public boolean equals(Object obj) {
return (this == obj);
}
```
之所以Integer或String能通过equals实现内容判等是因为它们都重写了这个方法。比如String的equals的实现
```
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
```
对于自定义类型如果不重写equals的话默认就是使用Object基类的按引用的比较方式。我们写一个自定义类测试一下。
假设有这样一个描述点的类Point有x、y和描述三个属性
```
class Point {
private int x;
private int y;
private final String desc;
public Point(int x, int y, String desc) {
this.x = x;
this.y = y;
this.desc = desc;
}
}
```
定义三个点p1、p2和p3其中p1和p2的描述属性不同p1和p3的三个属性完全相同并写一段代码测试一下默认行为
```
Point p1 = new Point(1, 2, &quot;a&quot;);
Point p2 = new Point(1, 2, &quot;b&quot;);
Point p3 = new Point(1, 2, &quot;a&quot;);
log.info(&quot;p1.equals(p2) ? {}&quot;, p1.equals(p2));
log.info(&quot;p1.equals(p3) ? {}&quot;, p1.equals(p3));
```
通过equals方法比较p1和p2、p1和p3均得到false原因正如刚才所说我们并没有为Point类实现自定义的equals方法Object超类中的equals默认使用==判等,比较的是对象的引用。
我们期望的逻辑是只要x和y这2个属性一致就代表是同一个点所以写出了如下的改进代码重写equals方法把参数中的Object转换为Point比较其x和y属性
```
class PointWrong {
private int x;
private int y;
private final String desc;
public PointWrong(int x, int y, String desc) {
this.x = x;
this.y = y;
this.desc = desc;
}
@Override
public boolean equals(Object o) {
PointWrong that = (PointWrong) o;
return x == that.x &amp;&amp; y == that.y;
}
}
```
为测试改进后的Point是否可以满足需求我们定义了三个用例
- 比较一个Point对象和null
- 比较一个Object对象和一个Point对象
- 比较两个x和y属性值相同的Point对象。
```
PointWrong p1 = new PointWrong(1, 2, &quot;a&quot;);
try {
log.info(&quot;p1.equals(null) ? {}&quot;, p1.equals(null));
} catch (Exception ex) {
log.error(ex.getMessage());
}
Object o = new Object();
try {
log.info(&quot;p1.equals(expression) ? {}&quot;, p1.equals(o));
} catch (Exception ex) {
log.error(ex.getMessage());
}
PointWrong p2 = new PointWrong(1, 2, &quot;b&quot;);
log.info(&quot;p1.equals(p2) ? {}&quot;, p1.equals(p2));
```
通过日志中的结果可以看到第一次比较出现了空指针异常第二次比较出现了类型转换异常第三次比较符合预期输出了true。
```
[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:32 ] - java.lang.NullPointerException
[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:39 ] - java.lang.ClassCastException: java.lang.Object cannot be cast to org.geekbang.time.commonmistakes.equals.demo1.EqualityMethodController$PointWrong
[17:54:39.120] [http-nio-45678-exec-1] [INFO ] [t.c.e.demo1.EqualityMethodController:43 ] - p1.equals(p2) ? true
```
**通过这些失效的用例我们大概可以总结出实现一个更好的equals应该注意的点**
- 考虑到性能可以先进行指针判等如果对象是同一个那么直接返回true
- 需要对另一方进行判空空对象和自身进行比较结果一定是fasle
- 需要判断两个对象的类型如果类型都不同那么直接返回false
- 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
修复和改进后的equals方法如下
```
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PointRight that = (PointRight) o;
return x == that.x &amp;&amp; y == that.y;
}
```
改进后的equals看起来完美了但还没完。我们继续往下看。
## hashCode和equals要配对实现
我们来试试下面这个用例定义两个x和y属性值完全一致的Point对象p1和p2把p1加入HashSet然后判断这个Set中是否存在p2
```
PointWrong p1 = new PointWrong(1, 2, &quot;a&quot;);
PointWrong p2 = new PointWrong(1, 2, &quot;b&quot;);
HashSet&lt;PointWrong&gt; points = new HashSet&lt;&gt;();
points.add(p1);
log.info(&quot;points.contains(p2) ? {}&quot;, points.contains(p2));
```
按照改进后的equals方法这2个对象可以认为是同一个Set中已经存在了p1就应该包含p2但结果却是false。
出现这个Bug的原因是散列表需要使用hashCode来定位元素放到哪个桶。如果自定义对象没有实现自定义的hashCode方法就会使用Object超类的默认实现**得到的两个hashCode是不同的导致无法满足需求**。
要自定义hashCode我们可以直接使用Objects.hash方法来实现改进后的Point类如下
```
class PointRight {
private final int x;
private final int y;
private final String desc;
...
@Override
public boolean equals(Object o) {
...
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
```
改进equals和hashCode后再测试下之前的四个用例结果全部符合预期。
```
[18:25:23.091] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:54 ] - p1.equals(null) ? false
[18:25:23.093] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:61 ] - p1.equals(expression) ? false
[18:25:23.094] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:67 ] - p1.equals(p2) ? true
[18:25:23.094] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:71 ] - points.contains(p2) ? true
```
看到这里你可能会觉得自己实现equals和hashCode很麻烦实现equals有很多注意点而且代码量很大。不过实现这两个方法也有简单的方式一是后面要讲到的Lombok方法二是使用IDE的代码生成功能。IDEA的类代码快捷生成菜单支持的功能如下
<img src="https://static001.geekbang.org/resource/image/94/36/944fe3549e4c24936e9837d0bf1e3936.jpg" alt="">
## 注意compareTo和equals的逻辑一致性
除了自定义类型需要确保equals和hashCode要逻辑一致外还有一个更容易被忽略的问题即compareTo同样需要和equals确保逻辑一致性。
我之前遇到过这么一个问题代码里本来使用了ArrayList的indexOf方法进行元素搜索但是一位好心的开发同学觉得逐一比较的时间复杂度是O(n)效率太低了于是改为了排序后通过Collections.binarySearch方法进行搜索实现了O(log n)的时间复杂度。没想到这么一改却出现了Bug。
我们来重现下这个问题。首先定义一个Student类有id和name两个属性并实现了一个Comparable接口来返回两个id的值
```
@Data
@AllArgsConstructor
class Student implements Comparable&lt;Student&gt;{
private int id;
private String name;
@Override
public int compareTo(Student other) {
int result = Integer.compare(other.id, id);
if (result==0)
log.info(&quot;this {} == other {}&quot;, this, other);
return result;
}
}
```
然后写一段测试代码分别通过indexOf方法和Collections.binarySearch方法进行搜索。列表中我们存放了两个学生第一个学生id是1叫zhang第二个学生id是2叫wang搜索这个列表是否存在一个id是2叫li的学生
```
@GetMapping(&quot;wrong&quot;)
public void wrong(){
List&lt;Student&gt; list = new ArrayList&lt;&gt;();
list.add(new Student(1, &quot;zhang&quot;));
list.add(new Student(2, &quot;wang&quot;));
Student student = new Student(2, &quot;li&quot;);
log.info(&quot;ArrayList.indexOf&quot;);
int index1 = list.indexOf(student);
Collections.sort(list);
log.info(&quot;Collections.binarySearch&quot;);
int index2 = Collections.binarySearch(list, student);
log.info(&quot;index1 = &quot; + index1);
log.info(&quot;index2 = &quot; + index2);
}
```
代码输出的日志如下:
```
[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:28 ] - ArrayList.indexOf
[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:31 ] - Collections.binarySearch
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:67 ] - this CompareToController.Student(id=2, name=wang) == other CompareToController.Student(id=2, name=li)
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:34 ] - index1 = -1
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:35 ] - index2 = 1
```
我们注意到如下几点:
- **binarySearch方法内部调用了元素的compareTo方法进行比较**
- indexOf的结果没问题列表中搜索不到id为2、name是li的学生
- binarySearch返回了索引1代表搜索到的结果是id为2name是wang的学生。
修复方式很简单确保compareTo的比较逻辑和equals的实现一致即可。重新实现一下Student类通过Comparator.comparing这个便捷的方法来实现两个字段的比较
```
@Data
@AllArgsConstructor
class StudentRight implements Comparable&lt;StudentRight&gt;{
private int id;
private String name;
@Override
public int compareTo(StudentRight other) {
return Comparator.comparing(StudentRight::getName)
.thenComparingInt(StudentRight::getId)
.compare(this, other);
}
}
```
其实,这个问题容易被忽略的原因在于两方面:
- 一是我们使用了Lombok的@Data标记了Student@Data注解(详见[这里](https://projectlombok.org/features/Data))其实包含了@EqualsAndHashCode注解(详见[这里](https://projectlombok.org/features/EqualsAndHashCode)的作用也就是默认情况下使用类型所有的字段不包括static和transient字段参与到equals和hashCode方法的实现中。因为这两个方法的实现不是我们自己实现的所以容易忽略其逻辑。
- 二是compareTo方法需要返回数值作为排序的依据容易让人使用数值类型的字段随意实现。
我再强调下,**对于自定义的类型如果要实现Comparable请记得equals、hashCode、compareTo三者逻辑一致**。
## 小心Lombok生成代码的“坑”
Lombok的@Data注解会帮我们实现equals和hashcode方法但是有继承关系时Lombok自动生成的方法可能就不是我们期望的了。
我们先来研究一下其实现定义一个Person类型包含姓名和身份证两个字段
```
@Data
class Person {
private String name;
private String identity;
public Person(String name, String identity) {
this.name = name;
this.identity = identity;
}
}
```
对于身份证相同、姓名不同的两个Person对象
```
Person person1 = new Person(&quot;zhuye&quot;,&quot;001&quot;);
Person person2 = new Person(&quot;Joseph&quot;,&quot;001&quot;);
log.info(&quot;person1.equals(person2) ? {}&quot;, person1.equals(person2));
```
使用equals判等会得到false。如果你希望只要身份证一致就认为是同一个人的话可以使用@EqualsAndHashCode.Exclude注解来修饰name字段从equals和hashCode的实现中排除name字段
```
@EqualsAndHashCode.Exclude
private String name;
```
修改后得到true。打开编译后的代码可以看到Lombok为Person生成的equals方法的实现确实只包含了identity属性
```
public boolean equals(final Object o) {
if (o == this) {
return true;
} else if (!(o instanceof LombokEquealsController.Person)) {
return false;
} else {
LombokEquealsController.Person other = (LombokEquealsController.Person)o;
if (!other.canEqual(this)) {
return false;
} else {
Object this$identity = this.getIdentity();
Object other$identity = other.getIdentity();
if (this$identity == null) {
if (other$identity != null) {
return false;
}
} else if (!this$identity.equals(other$identity)) {
return false;
}
return true;
}
}
}
```
但到这里还没完如果类型之间有继承Lombok会怎么处理子类的equals和hashCode呢我们来测试一下写一个Employee类继承Person并新定义一个公司属性
```
@Data
class Employee extends Person {
private String company;
public Employee(String name, String identity, String company) {
super(name, identity);
this.company = company;
}
}
```
在如下的测试代码中声明两个Employee实例它们具有相同的公司名称但姓名和身份证均不同
```
Employee employee1 = new Employee(&quot;zhuye&quot;,&quot;001&quot;, &quot;bkjk.com&quot;);
Employee employee2 = new Employee(&quot;Joseph&quot;,&quot;002&quot;, &quot;bkjk.com&quot;);
log.info(&quot;employee1.equals(employee2) ? {}&quot;, employee1.equals(employee2));
```
很遗憾结果是true显然是没有考虑父类的属性而认为这两个员工是同一人**说明@EqualsAndHashCode默认实现没有使用父类属性。**
为解决这个问题我们可以手动设置callSuper开关为true来覆盖这种默认行为
```
@Data
@EqualsAndHashCode(callSuper = true)
class Employee extends Person {
```
修改后的代码实现了同时以子类的属性company加上父类中的属性identity作为equals和hashCode方法的实现条件实现上其实是调用了父类的equals和hashCode
## 重点回顾
现在,我们来回顾下对象判等和比较的重点内容吧。
首先我们要注意equals和== 的区别。业务代码中进行内容的比较,针对基本类型只能使用==针对Integer、String在内的引用类型需要使用equals。Integer和String的坑在于使用==判等有时也能获得正确结果。
其次对于自定义类型如果类型需要参与判等那么务必同时实现equals和hashCode方法并确保逻辑一致。如果希望快速实现equals、hashCode方法我们可以借助IDE的代码生成功能或使用Lombok来生成。如果类型也要参与比较那么compareTo方法的逻辑同样需要和equals、hashCode方法一致。
最后Lombok的@EqualsAndHashCode注解实现equals和hashCode的时候默认使用类型所有非static、非transient的字段且不考虑父类。如果希望改变这种默认行为可以使用@EqualsAndHashCode.Exclude排除一些字段并设置callSuper = true来让子类的equals和hashCode调用父类的相应方法。
在比较枚举值和POJO参数值的例子中我们还可以注意到使用==来判断两个包装类型的低级错误,确实容易被忽略。所以,**我建议你在IDE中安装阿里巴巴的Java规约插件**(详见[这里](https://github.com/alibaba/p3c)),来及时提示我们这类低级错误:
<img src="https://static001.geekbang.org/resource/image/fe/c3/fe020d747a35cec23e5d92c1277d02c3.png" alt="">
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在实现equals时我是先通过getClass方法判断两个对象的类型你可能会想到还可以使用instanceof来判断。你能说说这两种实现方式的区别吗
1. 在第三节的例子中我演示了可以通过HashSet的contains方法判断元素是否在HashSet中同样是Set的TreeSet其contains方法和HashSet有什么区别吗
有关对象判等、比较,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,373 @@
<audio id="audio" title="09 | 数值计算:注意精度、舍入和溢出问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/33/48b41f8dda454ee64d25947bebef7233.mp3"></audio>
你好,我是朱晔。今天,我要和你说说数值计算的精度、舍入和溢出问题。
之所以要单独分享数值计算是因为很多时候我们习惯的或者说认为理所当然的计算在计算器或计算机看来并不是那么回事儿。就比如前段时间爆出的一条新闻说是手机计算器把10%+10%算成了0.11而不是0.2。
出现这种问题的原因在于国外的计算程序使用的是单步计算法。在单步计算法中a+b%代表的是a*(1+b%)。所以手机计算器计算10%+10%时其实计算的是10%*1+10%所以得到的是0.11而不是0.2。
在我看来,计算器或计算机会得到反直觉的计算结果的原因,可以归结为:
- 在人看来浮点数只是具有小数点的数字0.1和1都是一样精确的数字。但计算机其实无法精确保存浮点数因此浮点数的计算结果也不可能精确。
- 在人看来一个超大的数字只是位数多一点而已多写几个1并不会让大脑死机。但计算机是把数值保存在了变量中不同类型的数值变量能保存的数值范围不同当数值超过类型能表达的数值上限则会发生溢出问题。
接下来,我们就具体看看这些问题吧。
## “危险”的Double
我们先从简单的反直觉的四则运算看起。对几个简单的浮点数进行加减乘除运算:
```
System.out.println(0.1+0.2);
System.out.println(1.0-0.8);
System.out.println(4.015*100);
System.out.println(123.3/100);
double amount1 = 2.15;
double amount2 = 1.10;
if (amount1 - amount2 == 1.05)
System.out.println(&quot;OK&quot;);
```
输出结果如下:
```
0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999
```
可以看到输出结果和我们预期的很不一样。比如0.1+0.2输出的不是0.3而是0.30000000000000004再比如对2.15-1.10和1.05判等,结果判等不成立。
出现这种问题的主要原因是计算机是以二进制存储数值的浮点数也不例外。Java采用了[IEEE 754标准](https://en.wikipedia.org/wiki/IEEE_754)实现浮点数的表达和运算,你可以通过[这里](http://www.binaryconvert.com/)查看数值转化为二进制的结果。
比如0.1的二进制表示为0.0 0011 0011 0011… 0011 无限循环)再转换为十进制就是0.1000000000000000055511151231257827021181583404541015625。**对于计算机而言0.1无法精确表达,这是浮点数计算造成精度损失的根源。**
你可能会说以0.1为例其十进制和二进制间转换后相差非常小不会对计算产生什么影响。但所谓积土成山如果大量使用double来作大量的金钱计算最终损失的精度就是大量的资金出入。比如每天有一百万次交易每次交易都差一分钱一个月下来就差30万。这就不是小事儿了。那如何解决这个问题呢
我们大都听说过BigDecimal类型浮点数精确表达和运算的场景一定要使用这个类型。不过在使用BigDecimal时有几个坑需要避开。我们用BigDecimal把之前的四则运算改一下
```
System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));
```
输出如下:
```
0.3000000000000000166533453693773481063544750213623046875
0.1999999999999999555910790149937383830547332763671875
401.49999999999996802557689079549163579940795898437500
1.232999999999999971578290569595992565155029296875
```
可以看到,运算结果还是不精确,只不过是精度高了而已。这里给出浮点数运算避坑第一原则:**使用BigDecimal表示和计算浮点数且务必使用字符串的构造方法来初始化BigDecimal**
```
System.out.println(new BigDecimal(&quot;0.1&quot;).add(new BigDecimal(&quot;0.2&quot;)));
System.out.println(new BigDecimal(&quot;1.0&quot;).subtract(new BigDecimal(&quot;0.8&quot;)));
System.out.println(new BigDecimal(&quot;4.015&quot;).multiply(new BigDecimal(&quot;100&quot;)));
System.out.println(new BigDecimal(&quot;123.3&quot;).divide(new BigDecimal(&quot;100&quot;)));
```
改进后,就能得到我们想要的输出了:
```
0.3
0.2
401.500
1.233
```
到这里你可能会继续问不能调用BigDecimal传入Double的构造方法但手头只有一个Double如何转换为精确表达的BigDecimal呢
我们试试用Double.toString把double转换为字符串看看行不行
```
System.out.println(new BigDecimal(&quot;4.015&quot;).multiply(new BigDecimal(Double.toString(100))));
```
输出为401.5000。与上面字符串初始化100和4.015相乘得到的结果401.500相比这里为什么多了1个0呢原因就是BigDecimal有scale和precision的概念scale表示小数点右边的位数而precision表示精度也就是有效数字的长度。
调试一下可以发现new BigDecimal(Double.toString(100))得到的BigDecimal的scale=1、precision=4而new BigDecimal(“100”)得到的BigDecimal的scale=0、precision=3。对于BigDecimal乘法操作返回值的scale是两个数的scale相加。所以初始化100的两种不同方式导致最后结果的scale分别是4和3
```
private static void testScale() {
BigDecimal bigDecimal1 = new BigDecimal(&quot;100&quot;);
BigDecimal bigDecimal2 = new BigDecimal(String.valueOf(100d));
BigDecimal bigDecimal3 = new BigDecimal(String.valueOf(100));
BigDecimal bigDecimal4 = BigDecimal.valueOf(100d);
BigDecimal bigDecimal5 = new BigDecimal(Double.toString(100));
print(bigDecimal1); //scale 0 precision 3 result 401.500
print(bigDecimal2); //scale 1 precision 4 result 401.5000
print(bigDecimal3); //scale 0 precision 3 result 401.500
print(bigDecimal4); //scale 1 precision 4 result 401.5000
print(bigDecimal5); //scale 1 precision 4 result 401.5000
}
private static void print(BigDecimal bigDecimal) {
log.info(&quot;scale {} precision {} result {}&quot;, bigDecimal.scale(), bigDecimal.precision(), bigDecimal.multiply(new BigDecimal(&quot;4.015&quot;)));
}
```
BigDecimal的toString方法得到的字符串和scale相关又会引出了另一个问题对于浮点数的字符串形式输出和格式化我们应该考虑显式进行通过格式化表达式或格式化工具来明确小数位数和舍入方式。接下来我们就聊聊浮点数舍入和格式化。
## 考虑浮点数舍入和格式化的方式
除了使用Double保存浮点数可能带来精度问题外更匪夷所思的是这种精度问题加上String.format的格式化舍入方式可能得到让人摸不着头脑的结果。
我们看一个例子吧。首先用double和float初始化两个3.35的浮点数然后通过String.format使用%.1f来格式化这2个数字
```
double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format(&quot;%.1f&quot;, num1));//四舍五入
System.out.println(String.format(&quot;%.1f&quot;, num2));
```
得到的结果居然是3.4和3.3。
这就是由精度问题和舍入方式共同导致的double和float的3.35其实相当于3.350xxx和3.349xxx
```
3.350000000000000088817841970012523233890533447265625
3.349999904632568359375
```
String.format采用四舍五入的方式进行舍入取1位小数double的3.350四舍五入为3.4而float的3.349四舍五入为3.3。
**我们看一下Formatter类的相关源码可以发现使用的舍入模式是HALF_UP**代码第11行
```
else if (c == Conversion.DECIMAL_FLOAT) {
// Create a new BigDecimal with the desired precision.
int prec = (precision == -1 ? 6 : precision);
int scale = value.scale();
if (scale &gt; prec) {
// more &quot;scale&quot; digits than the requested &quot;precision&quot;
int compPrec = value.precision();
if (compPrec &lt;= scale) {
// case of 0.xxxxxx
value = value.setScale(prec, RoundingMode.HALF_UP);
} else {
compPrec -= (scale - prec);
value = new BigDecimal(value.unscaledValue(),
scale,
new MathContext(compPrec));
}
}
```
如果我们希望使用其他舍入方式来格式化字符串的话可以设置DecimalFormat如下代码所示
```
double num1 = 3.35;
float num2 = 3.35f;
DecimalFormat format = new DecimalFormat(&quot;#.##&quot;);
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num1));
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num2));
```
当我们把这2个浮点数向下舍入取2位小数时输出分别是3.35和3.34,还是我们之前说的浮点数无法精确存储的问题。
因此即使通过DecimalFormat来精确控制舍入方式double和float的问题也可能产生意想不到的结果所以浮点数避坑第二原则**浮点数的字符串格式化也要通过BigDecimal进行。**
比如下面这段代码使用BigDecimal来格式化数字3.35分别使用向下舍入和四舍五入方式取1位小数进行格式化
```
BigDecimal num1 = new BigDecimal(&quot;3.35&quot;);
BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
System.out.println(num2);
BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
System.out.println(num3);
```
这次得到的结果是3.3和3.4,符合预期。
## 用equals做判等就一定是对的吗
现在我们知道了应该使用BigDecimal来进行浮点数的表示、计算、格式化。在上一讲介绍[判等问题](https://time.geekbang.org/column/article/213604)时我提到一个原则包装类的比较要通过equals进行而不能使用==。那么使用equals方法对两个BigDecimal判等一定能得到我们想要的结果吗
我们来看下面的例子。使用equals方法比较1.0和1这两个BigDecimal
```
System.out.println(new BigDecimal(&quot;1.0&quot;).equals(new BigDecimal(&quot;1&quot;)))
```
你可能已经猜到我要说什么了结果当然是false。BigDecimal的equals方法的注释中说明了原因equals比较的是BigDecimal的value和scale1.0的scale是11的scale是0所以结果一定是false
```
/**
* Compares this {@code BigDecimal} with the specified
* {@code Object} for equality. Unlike {@link
* #compareTo(BigDecimal) compareTo}, this method considers two
* {@code BigDecimal} objects equal only if they are equal in
* value and scale (thus 2.0 is not equal to 2.00 when compared by
* this method).
*
* @param x {@code Object} to which this {@code BigDecimal} is
* to be compared.
* @return {@code true} if and only if the specified {@code Object} is a
* {@code BigDecimal} whose value and scale are equal to this
* {@code BigDecimal}'s.
* @see #compareTo(java.math.BigDecimal)
* @see #hashCode
*/
@Override
public boolean equals(Object x)
```
**如果我们希望只比较BigDecimal的value可以使用compareTo方法**,修改后代码如下:
```
System.out.println(new BigDecimal(&quot;1.0&quot;).compareTo(new BigDecimal(&quot;1&quot;))==0);
```
学过上一讲你可能会意识到BigDecimal的equals和hashCode方法会同时考虑value和scale如果结合HashSet或HashMap使用的话就可能会出现麻烦。比如我们把值为1.0的BigDecimal加入HashSet然后判断其是否存在值为1的BigDecimal得到的结果是false
```
Set&lt;BigDecimal&gt; hashSet1 = new HashSet&lt;&gt;();
hashSet1.add(new BigDecimal(&quot;1.0&quot;));
System.out.println(hashSet1.contains(new BigDecimal(&quot;1&quot;)));//返回false
```
解决这个问题的办法有两个:
- 第一个方法是使用TreeSet替换HashSet。TreeSet不使用hashCode方法也不使用equals比较元素而是使用compareTo方法所以不会有问题。
```
Set&lt;BigDecimal&gt; treeSet = new TreeSet&lt;&gt;();
treeSet.add(new BigDecimal(&quot;1.0&quot;));
System.out.println(treeSet.contains(new BigDecimal(&quot;1&quot;)));//返回true
```
- 第二个方法是把BigDecimal存入HashSet或HashMap前先使用stripTrailingZeros方法去掉尾部的零比较的时候也去掉尾部的0确保value相同的BigDecimalscale也是一致的
```
Set&lt;BigDecimal&gt; hashSet2 = new HashSet&lt;&gt;();
hashSet2.add(new BigDecimal(&quot;1.0&quot;).stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal(&quot;1.000&quot;).stripTrailingZeros()));//返回true
```
## 小心数值溢出问题
数值计算还有一个要小心的点是溢出不管是int还是long所有的基本数值类型都有超出表达范围的可能性。
比如对Long的最大值进行+1操作
```
long l = Long.MAX_VALUE;
System.out.println(l + 1);
System.out.println(l + 1 == Long.MIN_VALUE);
```
输出结果是一个负数因为Long的最大值+1变为了Long的最小值
```
-9223372036854775808
true
```
**显然这是发生了溢出,而且是默默地溢出,并没有任何异常**。这类问题非常容易被忽略改进方式有下面2种。
方法一是考虑使用Math类的addExact、subtractExact等xxExact方法进行数值运算这些方法可以在数值溢出时主动抛出异常。我们来测试一下使用Math.addExact对Long最大值做+1操作
```
try {
long l = Long.MAX_VALUE;
System.out.println(Math.addExact(l, 1));
} catch (Exception ex) {
ex.printStackTrace();
}
```
执行后可以得到ArithmeticException这是一个RuntimeException
```
java.lang.ArithmeticException: long overflow
at java.lang.Math.addExact(Math.java:809)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.right2(CommonMistakesApplication.java:25)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.main(CommonMistakesApplication.java:13)
```
方法二是使用大数类BigInteger。BigDecimal是处理浮点数的专家而BigInteger则是对大数进行科学计算的专家。
如下代码使用BigInteger对Long最大值进行+1操作如果希望把计算结果转换一个Long变量的话可以使用BigInteger的longValueExact方法在转换出现溢出时同样会抛出ArithmeticException
```
BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(i.add(BigInteger.ONE).toString());
try {
long l = i.add(BigInteger.ONE).longValueExact();
} catch (Exception ex) {
ex.printStackTrace();
}
```
输出结果如下:
```
9223372036854775808
java.lang.ArithmeticException: BigInteger out of long range
at java.math.BigInteger.longValueExact(BigInteger.java:4632)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.right1(CommonMistakesApplication.java:37)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.main(CommonMistakesApplication.java:11)
```
可以看到通过BigInteger对Long的最大值加1一点问题都没有当尝试把结果转换为Long类型时则会提示BigInteger out of long range。
## 重点回顾
今天,我与你分享了浮点数的表示、计算、舍入和格式化、溢出等涉及的一些坑。
第一切记要精确表示浮点数应该使用BigDecimal。并且使用BigDecimal的Double入参的构造方法同样存在精度丢失问题应该使用String入参的构造方法或者BigDecimal.valueOf方法来初始化。
第二对浮点数做精确计算参与计算的各种数值应该始终使用BigDecimal所有的计算都要通过BigDecimal的方法进行切勿只是让BigDecimal来走过场。任何一个环节出现精度损失最后的计算结果可能都会出现误差。
第三对于浮点数的格式化如果使用String.format的话需要认识到它使用的是四舍五入可以考虑使用DecimalFormat来明确指定舍入方式。但考虑到精度问题我更建议使用BigDecimal来表示浮点数并使用其setScale方法指定舍入的位数和方式。
第四进行数值运算时要小心溢出问题虽然溢出后不会出现异常但得到的计算结果是完全错误的。我们考虑使用Math.xxxExact方法来进行运算在溢出时能抛出异常更建议对于可能会出现溢出的大数运算使用BigInteger类。
总之对于金融、科学计算等场景请尽可能使用BigDecimal和BigInteger避免由精度和溢出问题引发难以发现但影响重大的Bug。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. [BigDecimal](https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html)提供了8种舍入模式你能通过一些例子说说它们的区别吗
1. 数据库比如MySQL中的浮点数和整型数字你知道应该怎样定义吗又如何实现浮点数的准确计算呢
针对数值运算,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,559 @@
<audio id="audio" title="10 | 集合类坑满地的List列表操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/c7/b5b350e25bdf6e625b4ee039f4c014c7.mp3"></audio>
你好我是朱晔。今天我来和你说说List列表操作有哪些坑。
Pascal之父尼克劳斯 · 维尔特Niklaus Wirth曾提出一个著名公式“程序=数据结构+算法”。由此可见数据结构的重要性。常见的数据结构包括List、Set、Map、Queue、Tree、Graph、Stack等其中List、Set、Map、Queue可以从广义上统称为集合类数据结构。
现代编程语言一般都会提供各种数据结构的实现供我们开箱即用。Java也是一样比如提供了集合类的各种实现。Java的集合类包括Map和Collection两大类。Collection包括List、Set和Queue三个小类其中List列表集合是最重要也是所有业务代码都会用到的。所以今天我会重点介绍List的内容而不会集中介绍Map以及Collection中其他小类的坑。
今天我们就从把数组转换为List集合、对List进行切片操作、List搜索的性能问题等几个方面着手来聊聊其中最可能遇到的一些坑。
## 使用Arrays.asList把数据转换为List的三个坑
Java 8中Stream流式处理的各种功能大大减少了集合类各种操作投影、过滤、转换的代码量。所以在业务开发中我们常常会把原始的数组转换为List类数据结构来继续展开各种Stream操作。
你可能也想到了使用Arrays.asList方法可以把数组一键转换为List但其实没这么简单。接下来就让我们看看其中的缘由以及使用Arrays.asList把数组转换为List的几个坑。
在如下代码中我们初始化三个数字的int[]数组然后使用Arrays.asList把数组转换为List
```
int[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
log.info(&quot;list:{} size:{} class:{}&quot;, list, list.size(), list.get(0).getClass());
```
这样初始化的List并不是我们期望的包含3个数字的List。通过日志可以发现这个List包含的其实是一个int数组整个List的元素个数是1元素类型是整数数组。
```
12:50:39.445 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - list:[[I@1c53fd30] size:1 class:class [I
```
其原因是只能是把int装箱为Integer不可能把int数组装箱为Integer数组。我们知道Arrays.asList方法传入的是一个泛型T类型可变参数最终int数组整体作为了一个对象成为了泛型类型T
```
public static &lt;T&gt; List&lt;T&gt; asList(T... a) {
return new ArrayList&lt;&gt;(a);
}
```
直接遍历这样的List必然会出现Bug修复方式有两种如果使用Java8以上版本可以使用Arrays.stream方法来转换否则可以把int数组声明为包装类型Integer数组
```
int[] arr1 = {1, 2, 3};
List list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());
log.info(&quot;list:{} size:{} class:{}&quot;, list1, list1.size(), list1.get(0).getClass());
Integer[] arr2 = {1, 2, 3};
List list2 = Arrays.asList(arr2);
log.info(&quot;list:{} size:{} class:{}&quot;, list2, list2.size(), list2.get(0).getClass());
```
修复后的代码得到如下日志可以看到List具有三个元素元素类型是Integer
```
13:10:57.373 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - list:[1, 2, 3] size:3 class:class java.lang.Integer
```
可以看到第一个坑是,**不能直接使用Arrays.asList来转换基本类型数组**。那么我们获得了正确的List是不是就可以像普通的List那样使用了呢我们继续往下看。
把三个字符串1、2、3构成的字符串数组使用Arrays.asList转换为List后将原始字符串数组的第二个字符修改为4然后为List增加一个字符串5最后数组和List会是怎样呢
```
String[] arr = {&quot;1&quot;, &quot;2&quot;, &quot;3&quot;};
List list = Arrays.asList(arr);
arr[1] = &quot;4&quot;;
try {
list.add(&quot;5&quot;);
} catch (Exception ex) {
ex.printStackTrace();
}
log.info(&quot;arr:{} list:{}&quot;, Arrays.toString(arr), list);
```
可以看到日志里有一个UnsupportedOperationException为List新增字符串5的操作失败了而且把原始数组的第二个元素从2修改为4后asList获得的List中的第二个元素也被修改为4了
```
java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
at org.geekbang.time.commonmistakes.collection.aslist.AsListApplication.wrong2(AsListApplication.java:41)
at org.geekbang.time.commonmistakes.collection.aslist.AsListApplication.main(AsListApplication.java:15)
13:15:34.699 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - arr:[1, 4, 3] list:[1, 4, 3]
```
这里,又引出了两个坑。
第二个坑,**Arrays.asList返回的List不支持增删操作。**Arrays.asList返回的List并不是我们期望的java.util.ArrayList而是Arrays的内部类ArrayList。ArrayList内部类继承自AbstractList类并没有覆写父类的add方法而父类中add方法的实现就是抛出UnsupportedOperationException。相关源码如下所示
```
public static &lt;T&gt; List&lt;T&gt; asList(T... a) {
return new ArrayList&lt;&gt;(a);
}
private static class ArrayList&lt;E&gt; extends AbstractList&lt;E&gt;
implements RandomAccess, java.io.Serializable
{
private final E[] a;
ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}
...
@Override
public E set(int index, E element) {
E oldValue = a[index];
a[index] = element;
return oldValue;
}
...
}
public abstract class AbstractList&lt;E&gt; extends AbstractCollection&lt;E&gt; implements List&lt;E&gt; {
...
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
}
```
第三个坑,**对原始数组的修改会影响到我们获得的那个List**。看一下ArrayList的实现可以发现ArrayList其实是直接使用了原始的数组。所以我们要特别小心把通过Arrays.asList获得的List交给其他方法处理很容易因为共享了数组相互修改产生Bug。
修复方式比较简单重新new一个ArrayList初始化Arrays.asList返回的List即可
```
String[] arr = {&quot;1&quot;, &quot;2&quot;, &quot;3&quot;};
List list = new ArrayList(Arrays.asList(arr));
arr[1] = &quot;4&quot;;
try {
list.add(&quot;5&quot;);
} catch (Exception ex) {
ex.printStackTrace();
}
log.info(&quot;arr:{} list:{}&quot;, Arrays.toString(arr), list);
```
修改后的代码实现了原始数组和List的“解耦”不再相互影响。同时因为操作的是真正的ArrayListadd也不再出错
```
13:34:50.829 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - arr:[1, 4, 3] list:[1, 2, 3, 5]
```
## 使用List.subList进行切片操作居然会导致OOM
业务开发时常常要对List做切片处理即取出其中部分元素构成一个新的List我们通常会想到使用List.subList方法。但和Arrays.asList的问题类似List.subList返回的子List不是一个普通的ArrayList。这个子List可以认为是原始List的视图会和原始List相互影响。如果不注意很可能会因此产生OOM问题。接下来我们就一起分析下其中的坑。
如下代码所示定义一个名为data的静态List来存放Integer的List也就是说data的成员本身是包含了多个数字的List。循环1000次每次都从一个具有10万个Integer的List中使用subList方法获得一个只包含一个数字的子List并把这个子List加入data变量
```
private static List&lt;List&lt;Integer&gt;&gt; data = new ArrayList&lt;&gt;();
private static void oom() {
for (int i = 0; i &lt; 1000; i++) {
List&lt;Integer&gt; rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
data.add(rawList.subList(0, 1));
}
}
```
你可能会觉得这个data变量里面最终保存的只是1000个具有1个元素的List不会占用很大空间但程序运行不久就出现了OOM
```
Exception in thread &quot;main&quot; java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:265)
```
**出现OOM的原因是循环中的1000个具有10万个元素的List始终得不到回收因为它始终被subList方法返回的List强引用。**那么返回的子List为什么会强引用原始的List它们又有什么关系呢我们再继续做实验观察一下这个子List的特性。
首先初始化一个包含数字1到10的ArrayList然后通过调用subList方法取出2、3、4随后删除这个SubList中的元素数字3并打印原始的ArrayList最后为原始的ArrayList增加一个元素数字0遍历SubList输出所有元素
```
List&lt;Integer&gt; list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
List&lt;Integer&gt; subList = list.subList(1, 4);
System.out.println(subList);
subList.remove(1);
System.out.println(list);
list.add(0);
try {
subList.forEach(System.out::println);
} catch (Exception ex) {
ex.printStackTrace();
}
```
代码运行后得到如下输出:
```
[2, 3, 4]
[1, 2, 4, 5, 6, 7, 8, 9, 10]
java.util.ConcurrentModificationException
at java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1239)
at java.util.ArrayList$SubList.listIterator(ArrayList.java:1099)
at java.util.AbstractList.listIterator(AbstractList.java:299)
at java.util.ArrayList$SubList.iterator(ArrayList.java:1095)
at java.lang.Iterable.forEach(Iterable.java:74)
```
可以看到两个现象:
- 原始List中数字3被删除了说明删除子List中的元素影响到了原始List
- 尝试为原始List增加数字0之后再遍历子List会出现ConcurrentModificationException。
我们分析下ArrayList的源码看看为什么会是这样。
```
public class ArrayList&lt;E&gt; extends AbstractList&lt;E&gt;
implements List&lt;E&gt;, RandomAccess, Cloneable, java.io.Serializable
{
protected transient int modCount = 0;
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length &gt; 0)
grow(minCapacity);
}
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
public List&lt;E&gt; subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, offset, fromIndex, toIndex);
}
private class SubList extends AbstractList&lt;E&gt; implements RandomAccess {
private final AbstractList&lt;E&gt; parent;
private final int parentOffset;
private final int offset;
int size;
SubList(AbstractList&lt;E&gt; parent,
int offset, int fromIndex, int toIndex) {
this.parent = parent;
this.parentOffset = fromIndex;
this.offset = offset + fromIndex;
this.size = toIndex - fromIndex;
this.modCount = ArrayList.this.modCount;
}
public E set(int index, E element) {
rangeCheck(index);
checkForComodification();
return l.set(index+offset, element);
}
public ListIterator&lt;E&gt; listIterator(final int index) {
checkForComodification();
...
}
private void checkForComodification() {
if (ArrayList.this.modCount != this.modCount)
throw new ConcurrentModificationException();
}
...
}
}
```
第一ArrayList维护了一个叫作modCount的字段表示集合结构性修改的次数。所谓结构性修改指的是影响List大小的修改所以add操作必然会改变modCount的值。
第二分析第21到24行的subList方法可以看到获得的List其实是**内部类SubList**并不是普通的ArrayList在初始化的时候传入了this。
第三分析第26到39行代码可以发现这个SubList中的parent字段就是原始的List。SubList初始化的时候并没有把原始List中的元素复制到独立的变量中保存。我们可以认为SubList是原始List的视图并不是独立的List。双方对元素的修改会相互影响而且SubList强引用了原始的List所以大量保存这样的SubList会导致OOM。
第四分析第47到55行代码可以发现遍历SubList的时候会先获得迭代器比较原始ArrayList modCount的值和SubList当前modCount的值。获得了SubList后我们为原始List新增了一个元素修改了其modCount所以判等失败抛出ConcurrentModificationException异常。
既然SubList相当于原始List的视图那么避免相互影响的修复方式有两种
- 一种是不直接使用subList方法返回的SubList而是重新使用new ArrayList在构造方法传入SubList来构建一个独立的ArrayList
- 另一种是对于Java 8使用Stream的skip和limit API来跳过流中的元素以及限制流中元素的个数同样可以达到SubList切片的目的。
```
//方式一:
List&lt;Integer&gt; subList = new ArrayList&lt;&gt;(list.subList(1, 4));
//方式二:
List&lt;Integer&gt; subList = list.stream().skip(1).limit(3).collect(Collectors.toList());
```
修复后代码输出如下:
```
[2, 3, 4]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
2
4
```
可以看到删除SubList的元素不再影响原始List而对原始List的修改也不会再出现List迭代异常。
## 一定要让合适的数据结构做合适的事情
在介绍[并发工具](https://time.geekbang.org/column/article/209494)时我提到要根据业务场景选择合适的并发工具或容器。在使用List集合类的时候不注意使用场景也会遇见两个常见误区。
**第一个误区是,使用数据结构不考虑平衡时间和空间**
首先定义一个只有一个int类型订单号字段的Order类
```
@Data
@NoArgsConstructor
@AllArgsConstructor
static class Order {
private int orderId;
}
```
然后定义一个包含elementCount和loopCount两个参数的listSearch方法初始化一个具有elementCount个订单对象的ArrayList循环loopCount次搜索这个ArrayList每次随机搜索一个订单号
```
private static Object listSearch(int elementCount, int loopCount) {
List&lt;Order&gt; list = IntStream.rangeClosed(1, elementCount).mapToObj(i -&gt; new Order(i)).collect(Collectors.toList());
IntStream.rangeClosed(1, loopCount).forEach(i -&gt; {
int search = ThreadLocalRandom.current().nextInt(elementCount);
Order result = list.stream().filter(order -&gt; order.getOrderId() == search).findFirst().orElse(null);
Assert.assertTrue(result != null &amp;&amp; result.getOrderId() == search);
});
return list;
}
```
随后定义另一个mapSearch方法从一个具有elementCount个元素的Map中循环loopCount次查找随机订单号。Map的Key是订单号Value是订单对象
```
private static Object mapSearch(int elementCount, int loopCount) {
Map&lt;Integer, Order&gt; map = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toMap(Function.identity(), i -&gt; new Order(i)));
IntStream.rangeClosed(1, loopCount).forEach(i -&gt; {
int search = ThreadLocalRandom.current().nextInt(elementCount);
Order result = map.get(search);
Assert.assertTrue(result != null &amp;&amp; result.getOrderId() == search);
});
return map;
}
```
我们知道搜索ArrayList的时间复杂度是O(n)而HashMap的get操作的时间复杂度是O(1)。**所以要对大List进行单值搜索的话可以考虑使用HashMap其中Key是要搜索的值Value是原始对象会比使用ArrayList有非常明显的性能优势。**
如下代码所示对100万个元素的ArrayList和HashMap分别调用listSearch和mapSearch方法进行1000次搜索
```
int elementCount = 1000000;
int loopCount = 1000;
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;listSearch&quot;);
Object list = listSearch(elementCount, loopCount);
System.out.println(ObjectSizeCalculator.getObjectSize(list));
stopWatch.stop();
stopWatch.start(&quot;mapSearch&quot;);
Object map = mapSearch(elementCount, loopCount);
stopWatch.stop();
System.out.println(ObjectSizeCalculator.getObjectSize(map));
System.out.println(stopWatch.prettyPrint());
```
可以看到仅仅是1000次搜索listSearch方法耗时3.3秒而mapSearch耗时仅仅108毫秒。
```
20861992
72388672
StopWatch '': running time = 3506699764 ns
---------------------------------------------
ns % Task name
---------------------------------------------
3398413176 097% listSearch
108286588 003% mapSearch
```
即使我们要搜索的不是单值而是条件区间也可以尝试使用HashMap来进行“搜索性能优化”。如果你的条件区间是固定的话可以提前把HashMap按照条件区间进行分组Key就是不同的区间。
的确如果业务代码中有频繁的大ArrayList搜索使用HashMap性能会好很多。类似如果要对大ArrayList进行去重操作也不建议使用contains方法而是可以考虑使用HashSet进行去重。说到这里还有一个问题使用HashMap是否会牺牲空间呢
为此我们使用ObjectSizeCalculator工具打印ArrayList和HashMap的内存占用可以看到ArrayList占用内存21M而HashMap占用的内存达到了72M是List的三倍多。进一步使用MAT工具分析堆可以再次证明ArrayList在内存占用上性价比很高77%是实际的数据如第1个图所示16000000/20861992**而HashMap的“含金量”只有22%**如第2个图所示16000000/72386640
<img src="https://static001.geekbang.org/resource/image/1e/24/1e8492040dd4b1af6114a6eeba06e524.png" alt="">
<img src="https://static001.geekbang.org/resource/image/53/c7/53d53e3ce2efcb081f8d9fa496cb8ec7.png" alt="">
所以,在应用内存吃紧的情况下,我们需要考虑是否值得使用更多的内存消耗来换取更高的性能。这里我们看到的是平衡的艺术,空间换时间,还是时间换空间,只考虑任何一个方面都是不对的。
**第二个误区是过于迷信教科书的大O时间复杂度**
数据结构中要实现一个列表有基于连续存储的数组和基于指针串联的链表两种方式。在Java中有代表性的实现是ArrayList和LinkedList前者背后的数据结构是数组后者则是双向链表。
在选择数据结构的时候,我们通常会考虑每种数据结构不同操作的时间复杂度,以及使用场景两个因素。查看[这里](https://www.bigocheatsheet.com/)你可以看到数组和链表大O时间复杂度的显著差异
- 对于数组随机元素访问的时间复杂度是O(1)元素插入操作是O(n)
- 对于链表随机元素访问的时间复杂度是O(n)元素插入操作是O(1)。
那么在大量的元素插入、很少的随机访问的业务场景下是不是就应该使用LinkedList呢接下来我们写一段代码测试下两者随机访问和插入的性能吧。
定义四个参数一致的方法分别对元素个数为elementCount的LinkedList和ArrayList循环loopCount次进行随机访问和增加元素到随机位置的操作
```
//LinkedList访问
private static void linkedListGet(int elementCount, int loopCount) {
List&lt;Integer&gt; list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(LinkedList::new));
IntStream.rangeClosed(1, loopCount).forEach(i -&gt; list.get(ThreadLocalRandom.current().nextInt(elementCount)));
}
//ArrayList访问
private static void arrayListGet(int elementCount, int loopCount) {
List&lt;Integer&gt; list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(ArrayList::new));
IntStream.rangeClosed(1, loopCount).forEach(i -&gt; list.get(ThreadLocalRandom.current().nextInt(elementCount)));
}
//LinkedList插入
private static void linkedListAdd(int elementCount, int loopCount) {
List&lt;Integer&gt; list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(LinkedList::new));
IntStream.rangeClosed(1, loopCount).forEach(i -&gt; list.add(ThreadLocalRandom.current().nextInt(elementCount),1));
}
//ArrayList插入
private static void arrayListAdd(int elementCount, int loopCount) {
List&lt;Integer&gt; list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(ArrayList::new));
IntStream.rangeClosed(1, loopCount).forEach(i -&gt; list.add(ThreadLocalRandom.current().nextInt(elementCount),1));
}
```
测试代码如下10万个元素循环10万次
```
int elementCount = 100000;
int loopCount = 100000;
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;linkedListGet&quot;);
linkedListGet(elementCount, loopCount);
stopWatch.stop();
stopWatch.start(&quot;arrayListGet&quot;);
arrayListGet(elementCount, loopCount);
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
StopWatch stopWatch2 = new StopWatch();
stopWatch2.start(&quot;linkedListAdd&quot;);
linkedListAdd(elementCount, loopCount);
stopWatch2.stop();
stopWatch2.start(&quot;arrayListAdd&quot;);
arrayListAdd(elementCount, loopCount);
stopWatch2.stop();
System.out.println(stopWatch2.prettyPrint());
```
运行结果可能会让你大跌眼镜。在随机访问方面我们看到了ArrayList的绝对优势耗时只有11毫秒而LinkedList耗时6.6秒,这符合上面我们所说的时间复杂度;**但随机插入操作居然也是LinkedList落败耗时9.3秒ArrayList只要1.5秒**
```
---------------------------------------------
ns % Task name
---------------------------------------------
6604199591 100% linkedListGet
011494583 000% arrayListGet
StopWatch '': running time = 10729378832 ns
---------------------------------------------
ns % Task name
---------------------------------------------
9253355484 086% linkedListAdd
1476023348 014% arrayListAdd
```
翻看LinkedList源码发现插入操作的时间复杂度是O(1)的前提是你已经有了那个要插入节点的指针。但在实现的时候我们需要先通过循环获取到那个节点的Node然后再执行插入操作。前者也是有开销的不可能只考虑插入操作本身的代价
```
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
Node&lt;E&gt; node(int index) {
// assert isElementIndex(index);
if (index &lt; (size &gt;&gt; 1)) {
Node&lt;E&gt; x = first;
for (int i = 0; i &lt; index; i++)
x = x.next;
return x;
} else {
Node&lt;E&gt; x = last;
for (int i = size - 1; i &gt; index; i--)
x = x.prev;
return x;
}
}
```
所以对于插入操作LinkedList的时间复杂度其实也是O(n)。继续做更多实验的话你会发现在各种常用场景下LinkedList几乎都不能在性能上胜出ArrayList。
讽刺的是LinkedList的作者约书亚 · 布洛克Josh Bloch在其推特上回复别人时说虽然LinkedList是我写的但我从来不用有谁会真的用吗
<img src="https://static001.geekbang.org/resource/image/12/cc/122a469eb03f16ab61d893ec57b34acc.png" alt="">
这告诉我们任何东西理论上和实际上是有差距的请勿迷信教科书的理论最好在下定论之前实际测试一下。抛开算法层面不谈由于CPU缓存、内存连续性等问题链表这种数据结构的实现方式对性能并不友好即使在它最擅长的场景都不一定可以发挥威力。
## 重点回顾
今天我分享了若干和List列表相关的错误案例基本都是由“想当然”导致的。
第一想当然认为Arrays.asList和List.subList得到的List是普通的、独立的ArrayList在使用时出现各种奇怪的问题。
- Arrays.asList得到的是Arrays的内部类ArrayListList.subList得到的是ArrayList的内部类SubList不能把这两个内部类转换为ArrayList使用。
- Arrays.asList直接使用了原始数组可以认为是共享“存储”而且不支持增删元素List.subList直接引用了原始的List也可以认为是共享“存储”而且对原始List直接进行结构性修改会导致SubList出现异常。
- 对Arrays.asList和List.subList容易忽略的是新的List持有了原始数据的引用可能会导致原始数据也无法GC的问题最终导致OOM。
第二想当然认为Arrays.asList一定可以把所有数组转换为正确的List。当传入基本类型数组的时候List的元素是数组本身而不是数组中的元素。
第三想当然认为内存中任何集合的搜索都是很快的结果在搜索超大ArrayList的时候遇到性能问题。我们考虑利用HashMap哈希表随机查找的时间复杂度为O(1)这个特性来优化性能不过也要考虑HashMap存储空间上的代价要平衡时间和空间。
第四想当然认为链表适合元素增删的场景选用LinkedList作为数据结构。在真实场景中读写增删一般是平衡的而且增删不可能只是对头尾对象进行操作可能在90%的情况下都得不到性能增益,建议使用之前通过性能测试评估一下。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
最后我给你留下与ArrayList在删除元素方面的坑有关的两个思考题吧。
1. 调用类型是Integer的ArrayList的remove方法删除元素传入一个Integer包装类的数字和传入一个int基本类型的数字结果一样吗
1. 循环遍历List调用remove方法删除元素往往会遇到ConcurrentModificationException异常原因是什么修复方式又是什么呢
你还遇到过与集合类相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,450 @@
<audio id="audio" title="11 | 空值处理分不清楚的null和恼人的空指针" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/a5/e1b83e296fe174074b0db02ec34723a5.mp3"></audio>
你好我是朱晔。今天我要和你分享的主题是空值处理分不清楚的null和恼人的空指针。
有一天我收到一条短信内容是“尊敬的null你好XXX”。当时我就笑了这是程序员都能Get的笑点程序没有获取到我的姓名然后把空格式化为了null。很明显这是没处理好null。哪怕把null替换为贵宾、顾客也不会引发这样的笑话。
程序中的变量是null就意味着它没有引用指向或者说没有指针。这时我们对这个变量进行任何操作都必然会引发空指针异常在Java中就是NullPointerException。那么空指针异常容易在哪些情况下出现又应该如何修复呢
空指针异常虽然恼人但好在容易定位更麻烦的是要弄清楚null的含义。比如客户端给服务端的一个数据是null那么其意图到底是给一个空值还是没提供值呢再比如数据库中字段的NULL值是否有特殊的含义呢针对数据库中的NULL值写SQL需要特别注意什么呢
今天就让我们带着这些问题开始null的踩坑之旅吧。
## 修复和定位恼人的空指针问题
**NullPointerException是Java代码中最常见的异常我将其最可能出现的场景归为以下5种**
- 参数值是Integer等包装类型使用时因为自动拆箱出现了空指针异常
- 字符串比较出现空指针异常;
- 诸如ConcurrentHashMap这样的容器不支持Key和Value为null强行put null的Key或Value会出现空指针异常
- A对象包含了B在通过A对象的字段获得B之后没有对字段判空就级联调用B的方法出现空指针异常
- 方法或远程服务返回的List不是空而是null没有进行判空就直接调用List的方法出现空指针异常。
为模拟说明这5种场景我写了一个wrongMethod方法并用一个wrong方法来调用它。wrong方法的入参test是一个由0和1构成的、长度为4的字符串第几位设置为1就代表第几个参数为null用来控制wrongMethod方法的4个入参以模拟各种空指针情况
```
private List&lt;String&gt; wrongMethod(FooService fooService, Integer i, String s, String t) {
log.info(&quot;result {} {} {} {}&quot;, i + 1, s.equals(&quot;OK&quot;), s.equals(t),
new ConcurrentHashMap&lt;String, String&gt;().put(null, null));
if (fooService.getBarService().bar().equals(&quot;OK&quot;))
log.info(&quot;OK&quot;);
return null;
}
@GetMapping(&quot;wrong&quot;)
public int wrong(@RequestParam(value = &quot;test&quot;, defaultValue = &quot;1111&quot;) String test) {
return wrongMethod(test.charAt(0) == '1' ? null : new FooService(),
test.charAt(1) == '1' ? null : 1,
test.charAt(2) == '1' ? null : &quot;OK&quot;,
test.charAt(3) == '1' ? null : &quot;OK&quot;).size();
}
class FooService {
@Getter
private BarService barService;
}
class BarService {
String bar() {
return &quot;OK&quot;;
}
}
```
很明显,这个案例出现空指针异常是因为变量是一个空指针,尝试获得变量的值或访问变量的成员会获得空指针异常。但,这个异常的定位比较麻烦。
在测试方法wrongMethod中我们通过一行日志记录的操作在一行代码中模拟了4处空指针异常
- 对入参Integer i进行+1操作
- 对入参String s进行比较操作判断内容是否等于"OK"
- 对入参String s和入参String t进行比较操作判断两者是否相等
- 对new出来的ConcurrentHashMap进行put操作Key和Value都设置为null。
输出的异常信息如下:
```
java.lang.NullPointerException: null
at org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController.wrongMethod(AvoidNullPointerExceptionController.java:37)
at org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController.wrong(AvoidNullPointerExceptionController.java:20)
```
这段信息确实提示了这行代码出现了空指针异常但我们很难定位出到底是哪里出现了空指针可能是把入参Integer拆箱为int的时候出现的也可能是入参的两个字符串任意一个为null也可能是因为把null加入了ConcurrentHashMap。
你可能会想到,要排查这样的问题,只要设置一个断点看一下入参即可。但,在真实的业务场景中,空指针问题往往是在特定的入参和代码分支下才会出现,本地难以重现。如果要排查生产上出现的空指针问题,设置代码断点不现实,通常是要么把代码进行拆分,要么增加更多的日志,但都比较麻烦。
在这里我推荐使用阿里开源的Java故障诊断神器[Arthas](https://alibaba.github.io/arthas/)。Arthas简单易用功能强大可以定位出大多数的Java生产问题。
接下来我就和你演示下如何在30秒内知道wrongMethod方法的入参从而定位到空指针到底是哪个入参引起的。如下截图中有三个红框我先和你分析第二和第三个红框
- 第二个红框表示Arthas启动后被附加到了JVM进程
- 第三个红框表示通过watch命令监控wrongMethod方法的入参。
<img src="https://static001.geekbang.org/resource/image/e2/6b/e2d39e5da91a8258c5aab3691e515c6b.png" alt="">
watch命令的参数包括类名表达式、方法表达式和观察表达式。这里我们设置观察类为AvoidNullPointerExceptionController观察方法为wrongMethod观察表达式为params表示观察入参
```
watch org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController wrongMethod params
```
开启watch后执行2次wrong方法分别设置test入参为1111和1101也就是第一次传入wrongMethod的4个参数都为null第二次传入的第1、2和4个参数为null。
配合图中第一和第四个红框可以看到第二次调用时第三个参数是字符串OK其他参数是nullArchas正确输出了方法的所有入参这样我们很容易就能定位到空指针的问题了。
到这里如果是简单的业务逻辑的话你就可以定位到空指针异常了如果是分支复杂的业务逻辑你需要再借助stack命令来查看wrongMethod方法的调用栈并配合watch命令查看各方法的入参就可以很方便地定位到空指针的根源了。
下图演示了通过stack命令观察wrongMethod的调用路径
<img src="https://static001.geekbang.org/resource/image/6c/ef/6c9ac7f4345936ece0b0d31c1ad974ef.png" alt="">
如果你想了解Arthas各种命令的详细使用方法可以[点击](https://alibaba.github.io/arthas/commands.html)这里查看。
接下来我们看看如何修复上面出现的5种空指针异常。
其实对于任何空指针异常的处理最直白的方式是先判空后操作。不过这只能让异常不再出现我们还是要找到程序逻辑中出现的空指针究竟是来源于入参还是Bug
- 如果是来源于入参,还要进一步分析入参是否合理等;
- 如果是来源于Bug那空指针不一定是纯粹的程序Bug可能还涉及业务属性和接口调用规范等。
在这里因为是Demo所以我们只考虑纯粹的空指针判空这种修复方式。如果要先判空后处理大多数人会想到使用if-else代码块。但这种方式既增加代码量又会降低易读性我们可以尝试利用Java 8的Optional类来消除这样的if-else逻辑使用一行代码进行判空和处理。
修复思路如下:
- 对于Integer的判空可以使用Optional.ofNullable来构造一个Optional<integer>然后使用orElse(0)把null替换为默认值再进行+1操作。</integer>
- 对于String和字面量的比较可以把字面量放在前面比如"OK".equals(s)这样即使s是null也不会出现空指针异常而对于两个可能为null的字符串变量的equals比较可以使用Objects.equals它会做判空处理。
- 对于ConcurrentHashMap既然其Key和Value都不支持null修复方式就是不要把null存进去。HashMap的Key和Value可以存入null而ConcurrentHashMap看似是HashMap的线程安全版本却不支持null值的Key和Value这是容易产生误区的一个地方。
- 对于类似fooService.getBarService().bar().equals(“OK”)的级联调用需要判空的地方有很多包括fooService、getBarService()方法的返回值以及bar方法返回的字符串。如果使用if-else来判空的话可能需要好几行代码但使用Optional的话一行代码就够了。
- 对于rightMethod返回的List<string>由于不能确认其是否为null所以在调用size方法获得列表大小之前同样可以使用Optional.ofNullable包装一下返回值然后通过.orElse(Collections.emptyList())实现在List为null的时候获得一个空的List最后再调用size方法。</string>
```
private List&lt;String&gt; rightMethod(FooService fooService, Integer i, String s, String t) {
log.info(&quot;result {} {} {} {}&quot;, Optional.ofNullable(i).orElse(0) + 1, &quot;OK&quot;.equals(s), Objects.equals(s, t), new HashMap&lt;String, String&gt;().put(null, null));
Optional.ofNullable(fooService)
.map(FooService::getBarService)
.filter(barService -&gt; &quot;OK&quot;.equals(barService.bar()))
.ifPresent(result -&gt; log.info(&quot;OK&quot;));
return new ArrayList&lt;&gt;();
}
@GetMapping(&quot;right&quot;)
public int right(@RequestParam(value = &quot;test&quot;, defaultValue = &quot;1111&quot;) String test) {
return Optional.ofNullable(rightMethod(test.charAt(0) == '1' ? null : new FooService(),
test.charAt(1) == '1' ? null : 1,
test.charAt(2) == '1' ? null : &quot;OK&quot;,
test.charAt(3) == '1' ? null : &quot;OK&quot;))
.orElse(Collections.emptyList()).size();
}
```
经过修复后调用right方法传入1111也就是给rightMethod的4个参数都设置为null日志中也看不到任何空指针异常了
```
[21:43:40.619] [http-nio-45678-exec-2] [INFO ] [.AvoidNullPointerExceptionController:45 ] - result 1 false true null
```
但是如果我们修改right方法入参为0000即传给rightMethod方法的4个参数都不可能是null最后日志中也无法出现OK字样。这又是为什么呢BarService的bar方法不是返回了OK字符串吗
我们还是用Arthas来定位问题使用watch命令来观察方法rightMethod的入参-x参数设置为2代表参数打印的深度为2层
<img src="https://static001.geekbang.org/resource/image/0c/82/0ce3c96788f243791cbd512aecfa6382.png" alt="">
可以看到FooService中的barService字段为null这样也就可以理解为什么最终出现这个Bug了。
这又引申出一个问题,**使用判空方式或Optional方式来避免出现空指针异常不一定是解决问题的最好方式空指针没出现可能隐藏了更深的Bug**。因此解决空指针异常还是要真正case by case地定位分析案例然后再去做判空处理而处理时也并不只是判断非空然后进行正常业务流程这么简单同样需要考虑为空的时候是应该出异常、设默认值还是记录日志等。
## POJO中属性的null到底代表了什么
在我看来相比判空避免空指针异常更容易出错的是null的定位问题。对程序来说null就是指针没有任何指向而结合业务逻辑情况就复杂得多我们需要考虑
- DTO中字段的null到底意味着什么是客户端没有传给我们这个信息吗
- 既然空指针问题很讨厌那么DTO中的字段要设置默认值么
- 如果数据库实体中的字段有null那么通过数据访问框架保存数据是否会覆盖数据库中的既有数据
如果不能明确地回答这些问题,那么写出的程序逻辑很可能会混乱不堪。接下来,我们看一个实际案例吧。
有一个User的POJO同时扮演DTO和数据库Entity角色包含用户ID、姓名、昵称、年龄、注册时间等属性
```
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private String name;
private String nickname;
private Integer age;
private Date createDate = new Date();
}
```
有一个Post接口用于更新用户数据更新逻辑非常简单根据用户姓名自动设置一个昵称昵称的规则是“用户类型+姓名”然后直接把客户端在RequestBody中使用JSON传过来的User对象通过JPA更新到数据库中最后返回保存到数据库的数据。
```
@Autowired
private UserRepository userRepository;
@PostMapping(&quot;wrong&quot;)
public User wrong(@RequestBody User user) {
user.setNickname(String.format(&quot;guest%s&quot;, user.getName()));
return userRepository.save(user);
}
@Repository
public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
}
```
首先在数据库中初始化一个用户age=36、name=zhuye、create_date=2020年1月4日、nickname是NULL
<img src="https://static001.geekbang.org/resource/image/de/67/de1bcb580ea63505a8e093c51c4cd567.png" alt="">
然后使用cURL测试一下用户信息更新接口Post传入一个id=1、name=null的JSON字符串期望把ID为1的用户姓名设置为空
```
curl -H &quot;Content-Type:application/json&quot; -X POST -d '{ &quot;id&quot;:1, &quot;name&quot;:null}' http://localhost:45678/pojonull/wrong
{&quot;id&quot;:1,&quot;name&quot;:null,&quot;nickname&quot;:&quot;guestnull&quot;,&quot;age&quot;:null,&quot;createDate&quot;:&quot;2020-01-05T02:01:03.784+0000&quot;}%
```
接口返回的结果和数据库中记录一致:
<img src="https://static001.geekbang.org/resource/image/af/fd/af9c07a63ba837683ad059a6afcceafd.png" alt="">
可以看到,这里存在如下三个问题:
- 调用方只希望重置用户名但age也被设置为了null
- nickname是用户类型加姓名name重置为null的话访客用户的昵称应该是guest而不是guestnull重现了文首提到的那个笑点
- 用户的创建时间原来是1月4日更新了用户信息后变为了1月5日。
归根结底这是如下5个方面的问题
- 明确DTO中null的含义。**对于JSON到DTO的反序列化过程null的表达是有歧义的客户端不传某个属性或者传null这个属性在DTO中都是null。**但对于用户信息更新操作不传意味着客户端不需要更新这个属性维持数据库原先的值传了null意味着客户端希望重置这个属性。因为Java中的null就是没有这个数据无法区分这两种表达所以本例中的age属性也被设置为了null或许我们可以借助Optional来解决这个问题。
- **POJO中的字段有默认值。如果客户端不传值就会赋值为默认值导致创建时间也被更新到了数据库中。**
- **注意字符串格式化时可能会把null值格式化为null字符串。**比如昵称的设置我们只是进行了简单的字符串格式化存入数据库变为了guestnull。显然这是不合理的也是开头我们说的笑话的来源还需要进行判断。
- **DTO和Entity共用了一个POJO**。对于用户昵称的设置是程序控制的我们不应该把它们暴露在DTO中否则很容易把客户端随意设置的值更新到数据库中。此外创建时间最好让数据库设置为当前时间不用程序控制可以通过在字段上设置columnDefinition来实现。
- **数据库字段允许保存null会进一步增加出错的可能性和复杂度**。因为如果数据真正落地的时候也支持NULL的话可能就有NULL、空字符串和字符串null三种状态。这一点我会在下一小节展开。如果所有属性都有默认值问题会简单一点。
按照这个思路我们对DTO和Entity进行拆分修改后代码如下所示
- UserDto中只保留id、name和age三个属性且name和age使用Optional来包装以区分客户端不传数据还是故意传null。
- 在UserEntity的字段上使用@Column注解把数据库字段name、nickname、age和createDate都设置为NOT NULL并设置createDate的默认值为CURRENT_TIMESTAMP由数据库来生成创建时间。
- 使用Hibernate的@DynamicUpdate注解实现更新SQL的动态生成实现只更新修改后的字段不过需要先查询一次实体让Hibernate可以“跟踪”实体属性的当前状态以确保有效。
```
@Data
public class UserDto {
private Long id;
private Optional&lt;String&gt; name;
private Optional&lt;Integer&gt; age;
;
@Data
@Entity
@DynamicUpdate
public class UserEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private Integer age;
@Column(nullable = false, columnDefinition = &quot;TIMESTAMP DEFAULT CURRENT_TIMESTAMP&quot;)
private Date createDate;
}
```
在重构了DTO和Entity后我们重新定义一个right接口以便对更新操作进行更精细化的处理。首先是参数校验
- 对传入的UserDto和ID属性先判空如果为空直接抛出IllegalArgumentException。
- 根据id从数据库中查询出实体后进行判空如果为空直接抛出IllegalArgumentException。
然后由于DTO中已经巧妙使用了Optional来区分客户端不传值和传null值那么业务逻辑实现上就可以按照客户端的意图来分别实现逻辑。如果不传值那么Optional本身为null直接跳过Entity字段的更新即可这样动态生成的SQL就不会包含这个列如果传了值那么进一步判断传的是不是null。
下面,我们根据业务需要分别对姓名、年龄和昵称进行更新:
- 对于姓名我们认为客户端传null是希望把姓名重置为空允许这样的操作使用Optional的orElse方法一键把空转换为空字符串即可。
- 对于年龄我们认为如果客户端希望更新年龄就必须传一个有效的年龄年龄不存在重置操作可以使用Optional的orElseThrow方法在值为空的时候抛出IllegalArgumentException。
- 对于昵称因为数据库中姓名不可能为null所以可以放心地把昵称设置为guest加上数据库取出来的姓名。
```
@PostMapping(&quot;right&quot;)
public UserEntity right(@RequestBody UserDto user) {
if (user == null || user.getId() == null)
throw new IllegalArgumentException(&quot;用户Id不能为空&quot;);
UserEntity userEntity = userEntityRepository.findById(user.getId())
.orElseThrow(() -&gt; new IllegalArgumentException(&quot;用户不存在&quot;));
if (user.getName() != null) {
userEntity.setName(user.getName().orElse(&quot;&quot;));
}
userEntity.setNickname(&quot;guest&quot; + userEntity.getName());
if (user.getAge() != null) {
userEntity.setAge(user.getAge().orElseThrow(() -&gt; new IllegalArgumentException(&quot;年龄不能为空&quot;)));
}
return userEntityRepository.save(userEntity);
}
```
假设数据库中已经有这么一条记录id=1、age=36、create_date=2020年1月4日、name=zhuye、nickname=guestzhuye
<img src="https://static001.geekbang.org/resource/image/5f/47/5f1d46ea87f37a570b32f94ac44ca947.png" alt="">
使用相同的参数调用right接口再来试试是否解决了所有问题。传入一个id=1、name=null的JSON字符串期望把id为1的用户姓名设置为空
```
curl -H &quot;Content-Type:application/json&quot; -X POST -d '{ &quot;id&quot;:1, &quot;name&quot;:null}' http://localhost:45678/pojonull/right
{&quot;id&quot;:1,&quot;name&quot;:&quot;&quot;,&quot;nickname&quot;:&quot;guest&quot;,&quot;age&quot;:36,&quot;createDate&quot;:&quot;2020-01-04T11:09:20.000+0000&quot;}%
```
结果如下:
<img src="https://static001.geekbang.org/resource/image/a6/4a/a68db8e14e7dca3ff9b22e2348272a4a.png" alt="">
可以看到right接口完美实现了仅重置name属性的操作昵称也不再有null字符串年龄和创建时间字段也没被修改。
通过日志可以看到Hibernate生成的SQL语句只更新了name和nickname两个字段
```
Hibernate: update user_entity set name=?, nickname=? where id=?
```
接下来为了测试使用Optional是否可以有效区分JSON中没传属性还是传了null我们在JSON中设置了一个null的age结果是正确得到了年龄不能为空的错误提示
```
curl -H &quot;Content-Type:application/json&quot; -X POST -d '{ &quot;id&quot;:1, &quot;age&quot;:null}' http://localhost:45678/pojonull/right
{&quot;timestamp&quot;:&quot;2020-01-05T03:14:40.324+0000&quot;,&quot;status&quot;:500,&quot;error&quot;:&quot;Internal Server Error&quot;,&quot;message&quot;:&quot;年龄不能为空&quot;,&quot;path&quot;:&quot;/pojonull/right&quot;}%
```
## 小心MySQL中有关NULL的三个坑
前面提到数据库表字段允许存NULL除了会让我们困惑外还容易有坑。这里我会结合NULL字段和你着重说明sum函数、count函数以及NULL值条件可能踩的坑。
为方便演示首先定义一个只有id和score两个字段的实体
```
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private Long score;
}
```
程序启动的时候往实体初始化一条数据其id是自增列自动设置的1score是NULL
```
@Autowired
private UserRepository userRepository;
@PostConstruct
public void init() {
userRepository.save(new User());
}
```
然后测试下面三个用例来看看结合数据库中的null值可能会出现的坑
- 通过sum函数统计一个只有NULL值的列的总和比如SUM(score)
- select记录数量count使用一个允许NULL的字段比如COUNT(score)
- 使用=NULL条件查询字段值为NULL的记录比如score=null条件。
```
@Repository
public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
@Query(nativeQuery=true,value = &quot;SELECT SUM(score) FROM `user`&quot;)
Long wrong1();
@Query(nativeQuery = true, value = &quot;SELECT COUNT(score) FROM `user`&quot;)
Long wrong2();
@Query(nativeQuery = true, value = &quot;SELECT * FROM `user` WHERE score=null&quot;)
List&lt;User&gt; wrong3();
}
```
得到的结果分别是null、0和空List
```
[11:38:50.137] [http-nio-45678-exec-1] [INFO ] [t.c.nullvalue.demo3.DbNullController:26 ] - result: null 0 []
```
显然这三条SQL语句的执行结果和我们的期望不同
- 虽然记录的score都是NULL但sum的结果应该是0才对
- 虽然这条记录的score是NULL但记录总数应该是1才对
- 使用=NULL并没有查询到id=1的记录查询条件失效。
原因是:
- **MySQL中sum函数没统计到任何记录时会返回null而不是0**可以使用IFNULL函数把null转换为0
- **MySQL中count字段不统计null值**COUNT(*)才是统计所有记录数量的正确方式。
- **MySQL中使用诸如=、&lt;&gt;这样的算数比较操作符比较NULL的结果总是NULL**这种比较就显得没有任何意义需要使用IS NULL、IS NOT NULL或 ISNULL()函数来比较。
修改一下SQL
```
@Query(nativeQuery = true, value = &quot;SELECT IFNULL(SUM(score),0) FROM `user`&quot;)
Long right1();
@Query(nativeQuery = true, value = &quot;SELECT COUNT(*) FROM `user`&quot;)
Long right2();
@Query(nativeQuery = true, value = &quot;SELECT * FROM `user` WHERE score IS NULL&quot;)
List&lt;User&gt; right3();
```
可以得到三个正确结果分别为0、1、[User(id=1, score=null)]
```
[14:50:35.768] [http-nio-45678-exec-1] [INFO ] [t.c.nullvalue.demo3.DbNullController:31 ] - result: 0 1 [User(id=1, score=null)]
```
## 重点回顾
今天,我和你讨论了做好空值处理需要注意的几个问题。
我首先总结了业务代码中5种最容易出现空指针异常的写法以及相应的修复方式。针对判空通过Optional配合Stream可以避免大多数冗长的if-else判空逻辑实现一行代码优雅判空。另外要定位和修复空指针异常除了可以通过增加日志进行排查外在生产上使用Arthas来查看方法的调用栈和入参会更快捷。
在我看来,业务系统最基本的标准是不能出现未处理的空指针异常,因为它往往代表了业务逻辑的中断,所以我建议每天查询一次生产日志来排查空指针异常,有条件的话建议订阅空指针异常报警,以便及时发现及时处理。
POJO中字段的null定位从服务端的角度往往很难分清楚到底是客户端希望忽略这个字段还是有意传了null因此我们尝试用Optional<t>类来区分null的定位。同时为避免把空值更新到数据库中可以实现动态SQL只更新必要的字段。</t>
最后我分享了数据库字段使用NULL可能会带来的三个坑包括sum函数、count函数以及NULL值条件以及解决方式。
总结来讲null的正确处理以及避免空指针异常绝不是判空这么简单还要根据业务属性从前到后仔细考虑客户端传入的null代表了什么出现了null是否允许使用默认值替代入库的时候应该传入null还是空值并确保整个逻辑处理的一致性才能尽量避免Bug。
为处理好null作为客户端的开发者需要和服务端对齐字段null的含义以及降级逻辑而作为服务端的开发者需要对入参进行前置判断提前挡掉服务端不可接受的空值同时在整个业务逻辑过程中进行完善的空值处理。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. ConcurrentHashMap的Key和Value都不能为null而HashMap却可以你知道这么设计的原因是什么吗TreeMap、Hashtable等Map的Key和Value是否支持null呢
1. 对于Hibernate框架可以使用@DynamicUpdate注解实现字段的动态更新对于MyBatis框架如何实现类似的动态SQL功能实现插入和修改SQL只包含POJO中的非空字段
关于程序和数据库中的null、空指针问题你还遇到过什么坑吗我是朱晔欢迎在评论区与我留言分享也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,592 @@
<audio id="audio" title="12 | 异常处理:别让自己在出问题的时候变为瞎子" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/42/8b3b488163b0f5f71dc823a0982b4b42.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊异常处理容易踩的坑。
应用程序避免不了出异常捕获和处理异常是考验编程功力的一个精细活。一些业务项目中我曾看到开发同学在开发业务逻辑时不考虑任何异常处理项目接近完成时再采用“流水线”的方式进行异常处理也就是统一为所有方法打上try…catch…捕获所有异常记录日志有些技巧的同学可能会使用AOP来进行类似的“统一异常处理”。
其实,这种处理异常的方式非常不可取。那么今天,我就和你分享下不可取的原因、与异常处理相关的坑和最佳实践。
## 捕获和处理异常容易犯的错
“统一异常处理”方式正是我要说的第一个错:**不在业务代码层面考虑异常处理,仅在框架层面粗犷捕获和处理异常**。
为了理解错在何处,我们先来看看大多数业务应用都采用的三层架构:
- Controller层负责信息收集、参数校验、转换服务层处理的数据适配前端轻业务逻辑
- Service层负责核心业务逻辑包括各种外部服务调用、访问数据库、缓存处理、消息处理等
- Repository层负责数据访问实现一般没有业务逻辑。
<img src="https://static001.geekbang.org/resource/image/2f/61/2f2cfbd86efd3bc140400bcaf2985361.png" alt="">
每层架构的工作性质不同,且从业务性质上异常可能分为业务异常和系统异常两大类,这就决定了很难进行统一的异常处理。我们从底向上看一下三层架构:
- Repository层出现异常或许可以忽略或许可以降级或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志很可能业务逻辑已经出错而用户和程序本身完全感知不到。
- Service层往往涉及数据库事务出现异常同样不适合捕获否则事务无法自动回滚。此外Service层涉及业务逻辑有些业务逻辑执行中遇到业务异常可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了业务功能就会不正常。
- 如果下层异常上升到Controller层还是无法处理的话Controller层往往会给予用户友好提示或是根据每一个API的异常表返回指定的异常类型同样无法对所有异常一视同仁。
因此,我不建议在框架层面进行异常的自动、统一处理,尤其不要随意捕获异常。但,框架可以做兜底工作。如果异常上升到最上层逻辑还是无法处理的话,可以以统一的方式进行异常转换,比如通过@RestControllerAdvice + @ExceptionHandler,来捕获这些“未处理”异常:
- 对于自定义的业务异常以Warn级别的日志记录异常以及当前URL、执行方法等信息后提取异常中的错误码和消息等信息转换为合适的API包装体返回给API调用方
- 对于无法处理的系统异常以Error级别的日志记录异常和上下文信息比如URL、参数、用户ID转换为普适的“服务器忙请稍后再试”异常信息同样以API包装体返回给调用方。
比如,下面这段代码的做法:
```
@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
private static int GENERIC_SERVER_ERROR_CODE = 2000;
private static String GENERIC_SERVER_ERROR_MESSAGE = &quot;服务器忙,请稍后再试&quot;;
@ExceptionHandler
public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
if (ex instanceof BusinessException) {
BusinessException exception = (BusinessException) ex;
log.warn(String.format(&quot;访问 %s -&gt; %s 出现业务异常!&quot;, req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, exception.getCode(), exception.getMessage());
} else {
log.error(String.format(&quot;访问 %s -&gt; %s 出现系统异常!&quot;, req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
}
}
}
```
出现运行时系统异常后异常处理程序会直接把异常转换为JSON返回给调用方
<img src="https://static001.geekbang.org/resource/image/c2/84/c215e78f1b23583393649fa89efe9f84.png" alt="">
要做得更好,你可以把相关出入参、用户信息在脱敏后记录到日志中,方便出现问题时根据上下文进一步排查。
第二个错,**捕获了异常后直接生吞**。在任何时候我们捕获了异常都不应该生吞也就是直接丢弃异常不记录、不抛出。这样的处理方式还不如不捕获异常因为被生吞掉的异常一旦导致Bug就很难在程序中找到蛛丝马迹使得Bug排查工作难上加难。
通常情况下,生吞异常的原因,可能是不希望自己的方法抛出受检异常,只是为了把异常“处理掉”而捕获并生吞异常,也可能是想当然地认为异常并不重要或不可能产生。但不管是什么原因,不管是你认为多么不重要的异常,都不应该生吞,哪怕是一个日志也好。
第三个错,**丢弃异常的原始信息**。我们来看两个不太合适的异常处理方式,虽然没有完全生吞异常,但也丢失了宝贵的异常信息。
比如有这么一个会抛出受检异常的方法readFile
```
private void readFile() throws IOException {
Files.readAllLines(Paths.get(&quot;a_file&quot;));
}
```
像这样调用readFile方法捕获异常后完全不记录原始异常直接抛出一个转换后异常导致出了问题不知道IOException具体是哪里引起的
```
@GetMapping(&quot;wrong1&quot;)
public void wrong1(){
try {
readFile();
} catch (IOException e) {
//原始异常信息丢失
throw new RuntimeException(&quot;系统忙请稍后再试&quot;);
}
}
```
或者是这样,只记录了异常消息,却丢失了异常的类型、栈等重要信息:
```
catch (IOException e) {
//只保留了异常消息,栈没有记录
log.error(&quot;文件读取错误, {}&quot;, e.getMessage());
throw new RuntimeException(&quot;系统忙请稍后再试&quot;);
}
```
留下的日志是这样的,看完一脸茫然,只知道文件读取错误的文件名,至于为什么读取错误、是不存在还是没权限,完全不知道。
```
[12:57:19.746] [http-nio-45678-exec-1] [ERROR] [.g.t.c.e.d.HandleExceptionController:35 ] - 文件读取错误, a_file
```
这两种处理方式都不太合理,可以改为如下方式:
```
catch (IOException e) {
log.error(&quot;文件读取错误&quot;, e);
throw new RuntimeException(&quot;系统忙请稍后再试&quot;);
}
```
或者把原始异常作为转换后新异常的cause原始异常信息同样不会丢
```
catch (IOException e) {
throw new RuntimeException(&quot;系统忙请稍后再试&quot;, e);
}
```
其实JDK内部也会犯类似的错。之前我遇到一个使用JDK10的应用偶发启动失败的案例日志中可以看到出现类似的错误信息
```
Caused by: java.lang.SecurityException: Couldn't parse jurisdiction policy files in: unlimited
at java.base/javax.crypto.JceSecurity.setupJurisdictionPolicies(JceSecurity.java:355)
at java.base/javax.crypto.JceSecurity.access$000(JceSecurity.java:73)
at java.base/javax.crypto.JceSecurity$1.run(JceSecurity.java:109)
at java.base/javax.crypto.JceSecurity$1.run(JceSecurity.java:106)
at java.base/java.security.AccessController.doPrivileged(Native Method)
at java.base/javax.crypto.JceSecurity.&lt;clinit&gt;(JceSecurity.java:105)
... 20 more
```
查看JDK JceSecurity类setupJurisdictionPolicies方法源码发现异常e没有记录也没有作为新抛出异常的cause当时读取文件具体出现什么异常权限问题又或是IO问题可能永远都无法知道了对问题定位造成了很大困扰
<img src="https://static001.geekbang.org/resource/image/b8/65/b8b581f6130211a19ed66490dbcd0465.png" alt="">
第四个错,**抛出异常时不指定任何消息**。我见过一些代码中的偷懒做法直接抛出没有message的异常
```
throw new RuntimeException();
```
这么写的同学可能觉得永远不会走到这个逻辑永远不会出现这样的异常。但这样的异常却出现了被ExceptionHandler拦截到后输出了下面的日志信息
```
[13:25:18.031] [http-nio-45678-exec-3] [ERROR] [c.e.d.RestControllerExceptionHandler:24 ] - 访问 /handleexception/wrong3 -&gt; org.geekbang.time.commonmistakes.exception.demo1.HandleExceptionController#wrong3(String) 出现系统异常!
java.lang.RuntimeException: null
...
```
这里的null非常容易引起误解。按照空指针问题排查半天才发现其实是异常的message为空。
总之,如果你捕获了异常打算处理的话,**除了通过日志正确记录异常原始信息外,通常还有三种处理模式**
- 转换即转换新的异常抛出。对于新抛出的异常最好具有特定的分类和明确的异常消息而不是随便抛一个无关或没有任何信息的异常并最好通过cause关联老异常。
- 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更严重,需要考虑当前情况是否适合重试。
- 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。
以上就是通过catch捕获处理异常的一些最佳实践。
## 小心finally中的异常
有些时候我们希望不管是否遇到异常逻辑完成后都要释放资源这时可以使用finally代码块而跳过使用catch代码块。
但要千万小心finally代码块中的异常因为资源释放处理等收尾操作同样也可能出现异常。比如下面这段代码我们在finally中抛出一个异常
```
@GetMapping(&quot;wrong&quot;)
public void wrong() {
try {
log.info(&quot;try&quot;);
//异常丢失
throw new RuntimeException(&quot;try&quot;);
} finally {
log.info(&quot;finally&quot;);
throw new RuntimeException(&quot;finally&quot;);
}
}
```
最后在日志中只能看到finally中的异常**虽然try中的逻辑出现了异常但却被finally中的异常覆盖了**。这是非常危险的特别是finally中出现的异常是偶发的就会在部分时候覆盖try中的异常让问题更不明显
```
[13:34:42.247] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: finally] with root cause
java.lang.RuntimeException: finally
```
至于异常为什么被覆盖原因也很简单因为一个方法无法出现两个异常。修复方式是finally代码块自己负责异常捕获和处理
```
@GetMapping(&quot;right&quot;)
public void right() {
try {
log.info(&quot;try&quot;);
throw new RuntimeException(&quot;try&quot;);
} finally {
log.info(&quot;finally&quot;);
try {
throw new RuntimeException(&quot;finally&quot;);
} catch (Exception ex) {
log.error(&quot;finally&quot;, ex);
}
}
}
```
或者可以把try中的异常作为主异常抛出使用addSuppressed方法把finally中的异常附加到主异常上
```
@GetMapping(&quot;right2&quot;)
public void right2() throws Exception {
Exception e = null;
try {
log.info(&quot;try&quot;);
throw new RuntimeException(&quot;try&quot;);
} catch (Exception ex) {
e = ex;
} finally {
log.info(&quot;finally&quot;);
try {
throw new RuntimeException(&quot;finally&quot;);
} catch (Exception ex) {
if (e!= null) {
e.addSuppressed(ex);
} else {
e = ex;
}
}
}
throw e;
}
```
运行方法可以得到如下异常信息,其中同时包含了主异常和被屏蔽的异常:
```
java.lang.RuntimeException: try
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:69)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
Suppressed: java.lang.RuntimeException: finally
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:75)
... 54 common frames omitted
```
其实这正是try-with-resources语句的做法对于实现了AutoCloseable接口的资源建议使用try-with-resources来释放资源否则也可能会产生刚才提到的释放资源时出现的异常覆盖主异常的问题。比如如下我们定义一个测试资源其read和close方法都会抛出异常
```
public class TestResource implements AutoCloseable {
public void read() throws Exception{
throw new Exception(&quot;read error&quot;);
}
@Override
public void close() throws Exception {
throw new Exception(&quot;close error&quot;);
}
}
```
使用传统的try-finally语句在try中调用read方法在finally中调用close方法
```
@GetMapping(&quot;useresourcewrong&quot;)
public void useresourcewrong() throws Exception {
TestResource testResource = new TestResource();
try {
testResource.read();
} finally {
testResource.close();
}
}
```
可以看到同样出现了finally中的异常覆盖了try中异常的问题
```
java.lang.Exception: close error
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourcewrong(FinallyIssueController.java:27)
```
而改为try-with-resources模式之后
```
@GetMapping(&quot;useresourceright&quot;)
public void useresourceright() throws Exception {
try (TestResource testResource = new TestResource()){
testResource.read();
}
}
```
try和finally中的异常信息都可以得到保留
```
java.lang.Exception: read error
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.read(TestResource.java:6)
...
Suppressed: java.lang.Exception: close error
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourceright(FinallyIssueController.java:35)
... 54 common frames omitted
```
## 千万别把异常定义为静态变量
既然我们通常会自定义一个业务异常类型来包含更多的异常信息比如异常错误码、友好的错误提示等那就需要在业务逻辑各处手动抛出各种业务异常来返回指定的错误码描述比如对于下单操作用户不存在返回2001商品缺货返回2002等
对于这些异常的错误代码和消息,我们期望能够统一管理,而不是散落在程序各处定义。这个想法很好,但稍有不慎就可能会出现把异常定义为静态变量的坑。
我在救火排查某项目生产问题时,遇到了一件非常诡异的事情:我发现异常堆信息显示的方法调用路径,在当前入参的情况下根本不可能产生,项目的业务逻辑又很复杂,就始终没往异常信息是错的这方面想,总觉得是因为某个分支流程导致业务没有按照期望的流程进行。
**经过艰难的排查,最终定位到原因是把异常定义为了静态变量,导致异常栈信息错乱**类似于定义一个Exceptions类来汇总所有的异常把异常存放在静态字段中
```
public class Exceptions {
public static BusinessException ORDEREXISTS = new BusinessException(&quot;订单已经存在&quot;, 3001);
...
}
```
把异常定义为静态变量会导致异常信息固化,这就和异常的栈一定是需要根据当前调用来动态获取相矛盾。
我们写段代码来模拟下这个问题定义两个方法createOrderWrong和cancelOrderWrong方法它们内部都会通过Exceptions类来获得一个订单不存在的异常先后调用两个方法然后抛出。
```
@GetMapping(&quot;wrong&quot;)
public void wrong() {
try {
createOrderWrong();
} catch (Exception ex) {
log.error(&quot;createOrder got error&quot;, ex);
}
try {
cancelOrderWrong();
} catch (Exception ex) {
log.error(&quot;cancelOrder got error&quot;, ex);
}
}
private void createOrderWrong() {
//这里有问题
throw Exceptions.ORDEREXISTS;
}
private void cancelOrderWrong() {
//这里有问题
throw Exceptions.ORDEREXISTS;
}
```
运行程序后看到如下日志cancelOrder got error的提示对应了createOrderWrong方法。显然cancelOrderWrong方法在出错后抛出的异常其实是createOrderWrong方法出错的异常
```
[14:05:25.782] [http-nio-45678-exec-1] [ERROR] [.c.e.d.PredefinedExceptionController:25 ] - cancelOrder got error
org.geekbang.time.commonmistakes.exception.demo2.BusinessException: 订单已经存在
at org.geekbang.time.commonmistakes.exception.demo2.Exceptions.&lt;clinit&gt;(Exceptions.java:5)
at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.createOrderWrong(PredefinedExceptionController.java:50)
at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.wrong(PredefinedExceptionController.java:18)
```
修复方式很简单改一下Exceptions类的实现通过不同的方法把每一种异常都new出来抛出即可
```
public class Exceptions {
public static BusinessException orderExists(){
return new BusinessException(&quot;订单已经存在&quot;, 3001);
}
}
```
## 提交线程池的任务出了异常会怎么样?
在[第3讲](https://time.geekbang.org/column/article/210337)介绍线程池时我提到,线程池常用作异步处理或并行处理。那么,把任务提交到线程池处理,任务本身出现异常时会怎样呢?
我们来看一个例子提交10个任务到线程池异步处理第5个任务抛出一个RuntimeException每个任务完成后都会输出一行日志
```
@GetMapping(&quot;execute&quot;)
public void execute() throws InterruptedException {
String prefix = &quot;test&quot;;
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix+&quot;%d&quot;).get());
//提交10个任务到线程池处理第5个任务会抛出运行时异常
IntStream.rangeClosed(1, 10).forEach(i -&gt; threadPool.execute(() -&gt; {
if (i == 5) throw new RuntimeException(&quot;error&quot;);
log.info(&quot;I'm done : {}&quot;, i);
}));
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}
```
观察日志可以发现两点:
```
...
[14:33:55.990] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 4
Exception in thread &quot;test0&quot; java.lang.RuntimeException: error
at org.geekbang.time.commonmistakes.exception.demo3.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:25)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
[14:33:55.990] [test1] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 6
...
```
- 任务1到4所在的线程是test0任务6开始运行在线程test1。由于我的线程池通过线程工厂为线程使用统一的前缀test加上计数器进行命名因此**从线程名的改变可以知道因为异常的抛出老线程退出了,线程池只能重新创建一个线程**。如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。
- 因为没有手动捕获异常进行处理ThreadGroup帮我们进行了未捕获异常的默认处理向标准错误输出打印了出现异常的线程名称和异常信息。**显然,这种没有以统一的错误日志格式记录错误信息打印出来的形式,对生产级代码是不合适的**ThreadGroup的相关源码如下所示
```
public void uncaughtException(Thread t, Throwable e) {
if (parent != null) {
parent.uncaughtException(t, e);
} else {
Thread.UncaughtExceptionHandler ueh =
Thread.getDefaultUncaughtExceptionHandler();
if (ueh != null) {
ueh.uncaughtException(t, e);
} else if (!(e instanceof ThreadDeath)) {
System.err.print(&quot;Exception in thread \&quot;&quot;
+ t.getName() + &quot;\&quot; &quot;);
e.printStackTrace(System.err);
}
}
}
```
修复方式有2步
1. 以execute方法提交到线程池的异步任务最好在任务内部做好异常处理
1. 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序:
```
new ThreadFactoryBuilder()
.setNameFormat(prefix+&quot;%d&quot;)
.setUncaughtExceptionHandler((thread, throwable)-&gt; log.error(&quot;ThreadPool {} got exception&quot;, thread, throwable))
.get()
```
或者设置全局的默认未捕获异常处理程序:
```
static {
Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-&gt; log.error(&quot;Thread {} got exception&quot;, thread, throwable));
}
```
通过线程池ExecutorService的execute方法提交任务到线程池处理如果出现异常会导致线程退出控制台输出中可以看到异常信息。那么把execute方法改为submit线程还会退出吗异常还能被处理程序捕获到吗
**修改代码后重新执行程序可以看到如下日志,说明线程没退出,异常也没记录被生吞了:**
```
[15:44:33.769] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 1
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 2
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 3
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 4
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 6
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 7
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 8
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 9
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 10
```
为什么会这样呢?
查看FutureTask源码可以发现在执行任务出现异常之后异常存到了一个outcome字段中只有在调用get方法获取FutureTask结果的时候才会以ExecutionException的形式重新抛出异常
```
public void run() {
...
try {
Callable&lt;V&gt; c = callable;
if (c != null &amp;&amp; state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
...
}
protected void setException(Throwable t) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = t;
UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
finishCompletion();
}
}
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s &lt;= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s &gt;= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
```
修改后的代码如下所示我们把submit返回的Future放到了List中随后遍历List来捕获所有任务的异常。这么做确实合乎情理。既然是以submit方式来提交任务那么我们应该关心任务的执行结果否则应该以execute来提交任务
```
List&lt;Future&gt; tasks = IntStream.rangeClosed(1, 10).mapToObj(i -&gt; threadPool.submit(() -&gt; {
if (i == 5) throw new RuntimeException(&quot;error&quot;);
log.info(&quot;I'm done : {}&quot;, i);
})).collect(Collectors.toList());
tasks.forEach(task-&gt; {
try {
task.get();
} catch (Exception e) {
log.error(&quot;Got exception&quot;, e);
}
});
```
执行这段程序可以看到如下的日志输出:
```
[15:44:13.543] [http-nio-45678-exec-1] [ERROR] [e.d.ThreadPoolAndExceptionController:69 ] - Got exception
java.util.concurrent.ExecutionException: java.lang.RuntimeException: error
```
## 重点回顾
在今天的文章中,我介绍了处理异常容易犯的几个错和最佳实践。
第一注意捕获和处理异常的最佳实践。首先不应该用AOP对所有方法进行统一异常处理异常要么不捕获不处理要么根据不同的业务逻辑、不同的异常类型进行精细化、针对性处理其次处理异常应该杜绝生吞并确保异常栈信息得到保留最后如果需要重新抛出异常的话请使用具有意义的异常类型和异常消息。
第二务必小心finally代码块中资源回收逻辑确保finally代码块不出现异常内部把异常处理完毕避免finally中的异常覆盖try中的异常或者考虑使用addSuppressed方法把finally中的异常附加到try中的异常上确保主异常信息不丢失。此外使用实现了AutoCloseable接口的资源务必使用try-with-resources模式来使用资源确保资源可以正确释放也同时确保异常可以正确处理。
第三虽然在统一的地方定义收口所有的业务异常是一个不错的实践但务必确保异常是每次new出来的而不能使用一个预先定义的static字段存放异常否则可能会引起栈信息的错乱。
第四确保正确处理了线程池中任务的异常如果任务通过execute提交那么出现异常会导致线程退出大量的异常会导致线程重复创建引起性能问题我们应该尽可能确保任务不出异常同时设置默认的未捕获异常处理程序来兜底如果任务通过submit提交意味着我们关心任务的执行结果应该通过拿到的Future调用其get方法来获得任务运行结果和可能出现的异常否则异常可能就被生吞了。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 关于在finally代码块中抛出异常的坑如果在finally代码块中返回值你觉得程序会以try或catch中返回值为准还是以finally中的返回值为准呢
1. 对于手动抛出的异常不建议直接使用Exception或RuntimeException通常建议复用JDK中的一些标准异常比如[IllegalArgumentException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalArgumentException.html)、[IllegalStateException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalStateException.html)、[UnsupportedOperationException](https://docs.oracle.com/javase/8/docs/api/java/lang/UnsupportedOperationException.html),你能说说它们的适用场景,并列出更多常用异常吗?
不知道针对异常处理,你还遇到过什么坑,还有什么最佳实践的心得吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,637 @@
<audio id="audio" title="13 | 日志:日志记录真没你想象的那么简单" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/e5/83c4a643db07af639fd5c4476632d1e5.mp3"></audio>
你好,我是朱晔。今天,我和你分享的是,记录日志可能会踩的坑。
一些同学可能要说了记录日志还不简单无非是几个常用的API方法比如debug、info、warn、error但我就见过不少坑都是记录日志引起的容易出错主要在于三个方面
- 日志框架众多,不同的类库可能会使用不同的日志框架,如何兼容是一个问题。
- 配置复杂且容易出错。日志配置文件通常很复杂,因此有些开发同学会从其他项目或者网络上复制一份配置文件,但却不知道如何修改,甚至是胡乱修改,造成很多问题。比如,重复记录日志的问题、同步日志的性能问题、异步记录的错误配置问题。
- 日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等。
Logback、Log4j、Log4j2、commons-logging、JDK自带的java.util.logging等都是Java体系的日志框架确实非常多。而不同的类库还可能选择使用不同的日志框架。这样一来日志的统一管理就变得非常困难。为了解决这个问题就有了SLF4JSimple Logging Facade For Java如下图所示
<img src="https://static001.geekbang.org/resource/image/97/fe/97fcd8b55e5288c0e9954f070f1008fe.png" alt="">
SLF4J实现了三种功能
- 一是提供了统一的日志门面API即图中紫色部分实现了中立的日志记录API。
- 二是桥接功能即图中蓝色部分用来把各种日志框架的API图中绿色部分桥接到SLF4J API。这样一来即便你的程序中使用了各种日志API记录日志最终都可以桥接到SLF4J门面API。
- 三是适配功能即图中红色部分可以实现SLF4J API和实际日志框架图中灰色部分的绑定。SLF4J只是日志标准我们还是需要一个实际的日志框架。日志框架本身没有实现SLF4J API所以需要有一个前置转换。Logback就是按照SLF4J API标准实现的因此不需要绑定模块做转换。
需要理清楚的是虽然我们可以使用log4j-over-slf4j来实现Log4j桥接到SLF4J也可以使用slf4j-log4j12实现SLF4J适配到Log4j也把它们画到了一列**但是它不能同时使用它们否则就会产生死循环。jcl和jul也是同样的道理。**
虽然图中有4个灰色的日志实现框架但我看到的业务系统使用最广泛的是Logback和Log4j它们是同一人开发的。Logback可以认为是Log4j的改进版本我更推荐使用。所以关于日志框架配置的案例我都会围绕Logback展开。
Spring Boot是目前最流行的Java框架它的日志框架也用的是Logback。那为什么我们没有手动引入Logback的包就可以直接使用Logback了呢
查看Spring Boot的Maven依赖树可以发现spring-boot-starter模块依赖了spring-boot-starter-logging模块而spring-boot-starter-logging模块又帮我们自动引入了logback-classic包含了SLF4J和Logback日志框架和SLF4J的一些适配器。其中log4j-to-slf4j用于实现Log4j2 API到SLF4J的桥接jul-to-slf4j则是实现java.util.logging API到SLF4J的桥接
<img src="https://static001.geekbang.org/resource/image/4c/e6/4c44672d280b8a30be777b78de6014e6.png" alt="">
接下来我就用几个实际的案例和你说说日志配置和记录这两大问题顺便以Logback为例复习一下常见的日志配置。
## 为什么我的日志会重复记录?
日志重复记录在业务上非常常见不但给查看日志和统计工作带来不必要的麻烦还会增加磁盘和日志收集系统的负担。接下来我和你分享两个重复记录的案例同时帮助你梳理Logback配置的基本结构。
**第一个案例是logger配置继承关系导致日志重复记录**。首先定义一个方法实现debug、info、warn和error四种日志的记录
```
@Log4j2
@RequestMapping(&quot;logging&quot;)
@RestController
public class LoggingController {
@GetMapping(&quot;log&quot;)
public void log() {
log.debug(&quot;debug&quot;);
log.info(&quot;info&quot;);
log.warn(&quot;warn&quot;);
log.error(&quot;error&quot;);
}
}
```
然后使用下面的Logback配置
- 第11和12行设置了全局的日志级别为INFO日志输出使用CONSOLE Appender。
- 第3到7行首先将CONSOLE Appender定义为ConsoleAppender也就是把日志输出到控制台System.out/System.err然后通过PatternLayout定义了日志的输出格式。关于格式化字符串的各种使用方式你可以进一步查阅[官方文档](http://logback.qos.ch/manual/layouts.html#conversionWord)。
- 第8到10行实现了一个Logger配置将应用包的日志级别设置为DEBUG、日志输出同样使用CONSOLE Appender。
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
&lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
&lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/layout&gt;
&lt;/appender&gt;
&lt;logger name=&quot;org.geekbang.time.commonmistakes.logging&quot; level=&quot;DEBUG&quot;&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
&lt;/logger&gt;
&lt;root level=&quot;INFO&quot;&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
&lt;/root&gt;
&lt;/configuration&gt;
```
这段配置看起来没啥问题,但执行方法后出现了日志重复记录的问题:
<img src="https://static001.geekbang.org/resource/image/2c/15/2c6f45bbbe06c1ed26b514e7ac873b15.png" alt="">
从配置文件的第9和12行可以看到CONSOLE这个Appender同时挂载到了两个Logger上一个是我们定义的&lt;logger&gt;,一个是&lt;root&gt;,由于我们定义的&lt;logger&gt;继承自&lt;root&gt;**所以同一条日志既会通过logger记录也会发送到root记录因此应用package下的日志出现了重复记录。**
后来我了解到这个同学如此配置的初衷是实现自定义的logger配置让应用内的日志暂时开启DEBUG级别的日志记录。其实他完全不需要重复挂载Appender去掉&lt;logger&gt;下挂载的Appender即可
```
&lt;logger name=&quot;org.geekbang.time.commonmistakes.logging&quot; level=&quot;DEBUG&quot;/&gt;
```
如果自定义的&lt;logger&gt;需要把日志输出到不同的Appender比如将应用的日志输出到文件app.log、把其他框架的日志输出到控制台可以设置&lt;logger&gt;的additivity属性为false这样就不会继承&lt;root&gt;的Appender了
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
&lt;appender name=&quot;FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
&lt;file&gt;app.log&lt;/file&gt;
&lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/encoder&gt;
&lt;/appender&gt;
&lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
&lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/layout&gt;
&lt;/appender&gt;
&lt;logger name=&quot;org.geekbang.time.commonmistakes.logging&quot; level=&quot;DEBUG&quot; additivity=&quot;false&quot;&gt;
&lt;appender-ref ref=&quot;FILE&quot;/&gt;
&lt;/logger&gt;
&lt;root level=&quot;INFO&quot;&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot; /&gt;
&lt;/root&gt;
&lt;/configuration&gt;
```
**第二个案例是错误配置LevelFilter造成日志重复记录。**
一般互联网公司都会使用ELK三件套来统一收集日志有一次我们发现Kibana上展示的日志有部分重复一直怀疑是Logstash配置错误但最后发现还是Logback的配置错误引起的。
这个项目的日志是这样配置的:在记录日志到控制台的同时,把日志记录按照不同的级别记录到两个文件中:
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
&lt;property name=&quot;logDir&quot; value=&quot;./logs&quot; /&gt;
&lt;property name=&quot;app.name&quot; value=&quot;common-mistakes&quot; /&gt;
&lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
&lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/layout&gt;
&lt;/appender&gt;
&lt;appender name=&quot;INFO_FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
&lt;File&gt;${logDir}/${app.name}_info.log&lt;/File&gt;
&lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
&lt;level&gt;INFO&lt;/level&gt;
&lt;/filter&gt;
&lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;charset&gt;UTF-8&lt;/charset&gt;
&lt;/encoder&gt;
&lt;/appender&gt;
&lt;appender name=&quot;ERROR_FILE&quot; class=&quot;ch.qos.logback.core.FileAppender
&quot;&gt;
&lt;File&gt;${logDir}/${app.name}_error.log&lt;/File&gt;
&lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
&lt;level&gt;WARN&lt;/level&gt;
&lt;/filter&gt;
&lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;charset&gt;UTF-8&lt;/charset&gt;
&lt;/encoder&gt;
&lt;/appender&gt;
&lt;root level=&quot;INFO&quot;&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot; /&gt;
&lt;appender-ref ref=&quot;INFO_FILE&quot;/&gt;
&lt;appender-ref ref=&quot;ERROR_FILE&quot;/&gt;
&lt;/root&gt;
&lt;/configuration&gt;
```
这个配置文件比较长,我带着你一段一段地看:
- 第31到35行定义的root引用了三个Appender。
- 第5到9行是第一个ConsoleAppender用于把所有日志输出到控制台。
- 第10到19行定义了一个FileAppender用于记录文件日志并定义了文件名、记录日志的格式和编码等信息。最关键的是第12到14行定义的LevelFilter过滤日志将过滤级别设置为INFO目的是希望_info.log文件中可以记录INFO级别的日志。
- 第20到30行定义了一个类似的FileAppender并使用ThresholdFilter来过滤日志过滤级别设置为WARN目的是把WARN以上级别的日志记录到另一个_error.log文件中。
运行一下测试程序:
<img src="https://static001.geekbang.org/resource/image/e9/4e/e940f1310e70b65ff716dc81c9901d4e.png" alt="">
可以看到_info.log中包含了INFO、WARN和ERROR三个级别的日志不符合我们的预期error.log包含了WARN和ERROR两个级别的日志。因此造成了日志的重复收集。
你可能会问这么明显的日志重复为什么没有及时发现一些公司使用自动化的ELK方案收集日志日志会同时输出到控制台和文件开发人员在本机测试时不太会关心文件中记录的日志而在测试和生产环境又因为开发人员没有服务器访问权限所以原始日志文件中的重复问题并不容易发现。
为了分析日志重复的原因我们来复习一下ThresholdFilter和LevelFilter的配置方式。
分析ThresholdFilter的源码发现当日志级别大于等于配置的级别时返回NEUTRAL继续调用过滤器链上的下一个过滤器否则返回DENY直接拒绝记录日志
```
public class ThresholdFilter extends Filter&lt;ILoggingEvent&gt; {
public FilterReply decide(ILoggingEvent event) {
if (!isStarted()) {
return FilterReply.NEUTRAL;
}
if (event.getLevel().isGreaterOrEqual(level)) {
return FilterReply.NEUTRAL;
} else {
return FilterReply.DENY;
}
}
}
```
在这个案例中把ThresholdFilter设置为WARN可以记录WARN和ERROR级别的日志。
LevelFilter用来比较日志级别然后进行相应处理如果匹配就调用onMatch定义的处理方式默认是交给下一个过滤器处理AbstractMatcherFilter基类中定义的默认值否则调用onMismatch定义的处理方式默认也是交给下一个过滤器处理。
```
public class LevelFilter extends AbstractMatcherFilter&lt;ILoggingEvent&gt; {
public FilterReply decide(ILoggingEvent event) {
if (!isStarted()) {
return FilterReply.NEUTRAL;
}
if (event.getLevel().equals(level)) {
return onMatch;
} else {
return onMismatch;
}
}
}
public abstract class AbstractMatcherFilter&lt;E&gt; extends Filter&lt;E&gt; {
protected FilterReply onMatch = FilterReply.NEUTRAL;
protected FilterReply onMismatch = FilterReply.NEUTRAL;
}
```
和ThresholdFilter不同的是LevelFilter仅仅配置level是无法真正起作用的。**由于没有配置onMatch和onMismatch属性所以相当于这个过滤器是无用的导致INFO以上级别的日志都记录了。**
定位到问题后修改方式就很明显了配置LevelFilter的onMatch属性为ACCEPT表示接收INFO级别的日志配置onMismatch属性为DENY表示除了INFO级别都不记录
```
&lt;appender name=&quot;INFO_FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
&lt;File&gt;${logDir}/${app.name}_info.log&lt;/File&gt;
&lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
&lt;level&gt;INFO&lt;/level&gt;
&lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
&lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
&lt;/filter&gt;
...
&lt;/appender&gt;
```
这样修改后_info.log文件中只会有INFO级别的日志不会出现日志重复的问题了。
## 使用异步日志改善性能的坑
掌握了把日志输出到文件中的方法后我们接下来面临的问题是如何避免日志记录成为应用的性能瓶颈。这可以帮助我们解决磁盘比如机械磁盘IO性能较差、日志量又很大的情况下如何记录日志的问题。
我们先来测试一下记录日志的性能问题定义如下的日志配置一共有两个Appender
- FILE是一个FileAppender用于记录所有的日志
- CONSOLE是一个ConsoleAppender用于记录带有time标记的日志。
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
&lt;appender name=&quot;FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
&lt;file&gt;app.log&lt;/file&gt;
&lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/encoder&gt;
&lt;/appender&gt;
&lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
&lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/layout&gt;
&lt;filter class=&quot;ch.qos.logback.core.filter.EvaluatorFilter&quot;&gt;
&lt;evaluator class=&quot;ch.qos.logback.classic.boolex.OnMarkerEvaluator&quot;&gt;
&lt;marker&gt;time&lt;/marker&gt;
&lt;/evaluator&gt;
&lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
&lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
&lt;/filter&gt;
&lt;/appender&gt;
&lt;root level=&quot;INFO&quot;&gt;
&lt;appender-ref ref=&quot;FILE&quot;/&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
&lt;/root&gt;
&lt;/configuration&gt;
```
不知道你有没有注意到这段代码中有个EvaluatorFilter求值过滤器用于判断日志是否符合某个条件。
在后续的测试代码中我们会把大量日志输出到文件中日志文件会非常大如果性能测试结果也混在其中的话就很难找到那条日志。所以这里我们使用EvaluatorFilter对日志按照标记进行过滤并将过滤出的日志单独输出到控制台上。在这个案例中我们给输出测试结果的那条日志上做了time标记。
配合使用标记和EvaluatorFilter实现日志的按标签过滤是一个不错的小技巧。
如下测试代码中实现了记录指定次数的大日志每条日志包含1MB字节的模拟数据最后记录一条以time为标记的方法执行耗时日志
```
@GetMapping(&quot;performance&quot;)
public void performance(@RequestParam(name = &quot;count&quot;, defaultValue = &quot;1000&quot;) int count) {
long begin = System.currentTimeMillis();
String payload = IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString();
IntStream.rangeClosed(1, count).forEach(i -&gt; log.info(&quot;{} {}&quot;, i, payload));
Marker timeMarker = MarkerFactory.getMarker(&quot;time&quot;);
log.info(timeMarker, &quot;took {} ms&quot;, System.currentTimeMillis() - begin);
}
```
执行程序后可以看到记录1000次日志和10000次日志的调用耗时分别是6.3秒和44.5秒:
<img src="https://static001.geekbang.org/resource/image/7e/52/7ee5152dedcbb585f23db49571bacc52.png" alt="">
对于只记录文件日志的代码了来说这个耗时挺长的。为了分析其中原因我们需要分析下FileAppender的源码。
FileAppender继承自OutputStreamAppender查看OutputStreamAppender源码的第30到33行发现**在追加日志的时候是直接把日志写入OutputStream中属于同步记录日志**
```
public class OutputStreamAppender&lt;E&gt; extends UnsynchronizedAppenderBase&lt;E&gt; {
private OutputStream outputStream;
boolean immediateFlush = true;
@Override
protected void append(E eventObject) {
if (!isStarted()) {
return;
}
subAppend(eventObject);
}
protected void subAppend(E event) {
if (!isStarted()) {
return;
}
try {
//编码LoggingEvent
byte[] byteArray = this.encoder.encode(event);
//写字节流
writeBytes(byteArray);
} catch (IOException ioe) {
...
}
}
private void writeBytes(byte[] byteArray) throws IOException {
if(byteArray == null || byteArray.length == 0)
return;
lock.lock();
try {
//这个OutputStream其实是一个ResilientFileOutputStream其内部使用的是带缓冲的BufferedOutputStream
this.outputStream.write(byteArray);
if (immediateFlush) {
this.outputStream.flush();//刷入OS
}
} finally {
lock.unlock();
}
}
}
```
分析到这里,我们就明白为什么日志大量写入时会耗时这么久了。那,有没有办法实现大量日志写入时,不会过多影响业务逻辑执行耗时,影响吞吐量呢?
办法当然有了使用Logback提供的AsyncAppender即可实现异步的日志记录。AsyncAppende类似装饰模式也就是在不改变类原有基本功能的情况下为其增添新功能。这样我们就可以把AsyncAppender附加在其他的Appender上将其变为异步的。
定义一个异步Appender ASYNCFILE包装之前的同步文件日志记录的FileAppender就可以实现异步记录日志到文件
```
&lt;appender name=&quot;ASYNCFILE&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
&lt;appender-ref ref=&quot;FILE&quot;/&gt;
&lt;/appender&gt;
&lt;root level=&quot;INFO&quot;&gt;
&lt;appender-ref ref=&quot;ASYNCFILE&quot;/&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
&lt;/root&gt;
```
测试一下可以发现记录1000次日志和10000次日志的调用耗时分别是735毫秒和668毫秒
<img src="https://static001.geekbang.org/resource/image/98/e1/98d1633d83734f9b8f08c3334b403ce1.png" alt="">
性能居然这么好,你觉得其中有什么问题吗?异步日志真的如此神奇和万能吗?当然不是,因为这样并没有记录下所有日志。**我之前就遇到过很多关于AsyncAppender异步日志的坑这些坑可以归结为三类**
- 记录异步日志撑爆内存;
- 记录异步日志出现日志丢失;
- 记录异步日志出现阻塞。
为了解释这三种坑我来模拟一个慢日志记录场景首先自定义一个继承自ConsoleAppender的MySlowAppender作为记录到控制台的输出器写入日志时休眠1秒。
```
public class MySlowAppender extends ConsoleAppender {
@Override
protected void subAppend(Object event) {
try {
// 模拟慢日志
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
super.subAppend(event);
}
}
```
然后在配置文件中使用AsyncAppender将MySlowAppender包装为异步日志记录
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
&lt;appender name=&quot;CONSOLE&quot; class=&quot;org.geekbang.time.commonmistakes.logging.async.MySlowAppender&quot;&gt;
&lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
&lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
&lt;/layout&gt;
&lt;/appender&gt;
&lt;appender name=&quot;ASYNC&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
&lt;appender-ref ref=&quot;CONSOLE&quot; /&gt;
&lt;/appender&gt;
&lt;root level=&quot;INFO&quot;&gt;
&lt;appender-ref ref=&quot;ASYNC&quot; /&gt;
&lt;/root&gt;
&lt;/configuration&gt;
```
定义一段测试代码,循环记录一定次数的日志,最后输出方法执行耗时:
```
@GetMapping(&quot;manylog&quot;)
public void manylog(@RequestParam(name = &quot;count&quot;, defaultValue = &quot;1000&quot;) int count) {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, count).forEach(i -&gt; log.info(&quot;log-{}&quot;, i));
System.out.println(&quot;took &quot; + (System.currentTimeMillis() - begin) + &quot; ms&quot;);
}
```
执行方法后发现耗时很短但出现了日志丢失我们要记录1000条日志最终控制台只能搜索到215条日志而且日志的行号变为了一个问号。
<img src="https://static001.geekbang.org/resource/image/5f/70/5fe1562b437b8672e4b1e9e463a24570.png" alt="">
出现这个问题的原因在于AsyncAppender提供了一些配置参数而我们没用对。我们结合相关源码分析一下
- includeCallerData用于控制是否收集调用方数据默认是false此时方法行号、方法名等信息将不能显示源码第2行以及7到11行
- queueSize用于控制阻塞队列大小使用的ArrayBlockingQueue阻塞队列源码第15到17行默认大小是256即内存中最多保存256条日志。
- discardingThreshold是控制丢弃日志的阈值主要是防止队列满后阻塞。默认情况下队列剩余量低于队列长度的20%就会丢弃TRACE、DEBUG和INFO级别的日志。参见源码第3到6行、18到19行、26到27行、33到34行、40到42行
- neverBlock用于控制队列满的时候加入的数据是否直接丢弃不会阻塞等待默认是false源码第44到68行。这里需要注意一下offer方法和put方法的区别当队列满的时候offer方法不阻塞而put方法会阻塞neverBlock为true时使用offer方法。
```
public class AsyncAppender extends AsyncAppenderBase&lt;ILoggingEvent&gt; {
boolean includeCallerData = false;//是否收集调用方数据
protected boolean isDiscardable(ILoggingEvent event) {
Level level = event.getLevel();
return level.toInt() &lt;= Level.INFO_INT;//丢弃&lt;=INFO级别的日志
}
protected void preprocess(ILoggingEvent eventObject) {
eventObject.prepareForDeferredProcessing();
if (includeCallerData)
eventObject.getCallerData();
}
}
public class AsyncAppenderBase&lt;E&gt; extends UnsynchronizedAppenderBase&lt;E&gt; implements AppenderAttachable&lt;E&gt; {
BlockingQueue&lt;E&gt; blockingQueue;//异步日志的关键,阻塞队列
public static final int DEFAULT_QUEUE_SIZE = 256;//默认队列大小
int queueSize = DEFAULT_QUEUE_SIZE;
static final int UNDEFINED = -1;
int discardingThreshold = UNDEFINED;
boolean neverBlock = false;//控制队列满的时候加入数据时是否直接丢弃,不会阻塞等待
@Override
public void start() {
...
blockingQueue = new ArrayBlockingQueue&lt;E&gt;(queueSize);
if (discardingThreshold == UNDEFINED)
discardingThreshold = queueSize / 5;//默认丢弃阈值是队列剩余量低于队列长度的20%参见isQueueBelowDiscardingThreshold方法
...
}
@Override
protected void append(E eventObject) {
if (isQueueBelowDiscardingThreshold() &amp;&amp; isDiscardable(eventObject)) { //判断是否可以丢数据
return;
}
preprocess(eventObject);
put(eventObject);
}
private boolean isQueueBelowDiscardingThreshold() {
return (blockingQueue.remainingCapacity() &lt; discardingThreshold);
}
private void put(E eventObject) {
if (neverBlock) { //根据neverBlock决定使用不阻塞的offer还是阻塞的put方法
blockingQueue.offer(eventObject);
} else {
putUninterruptibly(eventObject);
}
}
//以阻塞方式添加数据到队列
private void putUninterruptibly(E eventObject) {
boolean interrupted = false;
try {
while (true) {
try {
blockingQueue.put(eventObject);
break;
} catch (InterruptedException e) {
interrupted = true;
}
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
}
}
```
看到默认队列大小为256达到80%容量后开始丢弃&lt;=INFO级别的日志后我们就可以理解日志中为什么只有215条INFO日志了。
我们可以继续分析下异步记录日志出现坑的原因。
- queueSize设置得特别大就可能会导致OOM。
- queueSize设置得比较小默认值就非常小且discardingThreshold设置为大于0的值或者为默认值队列剩余容量少于discardingThreshold的配置就会丢弃&lt;=INFO的日志。这里的坑点有两个。一是因为discardingThreshold的存在设置queueSize时容易踩坑。比如本例中最大日志并发是1000即便设置queueSize为1000同样会导致日志丢失。二是discardingThreshold参数容易有歧义它不是百分比而是日志条数。对于总容量10000的队列如果希望队列剩余容量少于1000条的时候丢弃需要配置为1000。
- neverBlock默认为false意味着总可能会出现阻塞。如果discardingThreshold为0那么队列满时再有日志写入就会阻塞如果discardingThreshold不为0也只会丢弃&lt;=INFO级别的日志那么出现大量错误日志时还是会阻塞程序。
可以看出queueSize、discardingThreshold和neverBlock这三个参数息息相关务必按需进行设置和取舍到底是性能为先还是数据不丢为先
- 如果考虑绝对性能为先那就设置neverBlock为true永不阻塞。
- 如果考虑绝对不丢数据为先那就设置discardingThreshold为0即使是&lt;=INFO的级别日志也不会丢但最好把queueSize设置大一点毕竟默认的queueSize显然太小太容易阻塞。
- 如果希望兼顾两者可以丢弃不重要的日志把queueSize设置大一点再设置一个合理的discardingThreshold。
以上就是日志配置最常见的两个误区了。接下来,我们再看一个日志记录本身的误区。
## 使用日志占位符就不需要进行日志级别判断了?
不知道你有没有听人说过SLF4J的{}占位符语法,到真正记录日志时才会获取实际参数,因此解决了日志数据获取的性能问题。你觉得,这种说法对吗?
为了验证这个问题我们写一段测试代码有一个slowString方法返回结果耗时1秒
```
private String slowString(String s) {
System.out.println(&quot;slowString called via &quot; + s);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
return &quot;OK&quot;;
}
```
如果我们记录DEBUG日志并设置只记录&gt;=INFO级别的日志程序是否也会耗时1秒呢我们使用三种方法来测试
- 拼接字符串方式记录slowString
- 使用占位符方式记录slowString
- 先判断日志级别是否启用DEBUG。
```
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;debug1&quot;);
log.debug(&quot;debug1:&quot; + slowString(&quot;debug1&quot;));
stopWatch.stop();
stopWatch.start(&quot;debug2&quot;);
log.debug(&quot;debug2:{}&quot;, slowString(&quot;debug2&quot;));
stopWatch.stop();
stopWatch.start(&quot;debug3&quot;);
if (log.isDebugEnabled())
log.debug(&quot;debug3:{}&quot;, slowString(&quot;debug3&quot;));
stopWatch.stop();
```
可以看到前两种方式都调用了slowString方法所以耗时都是1秒
<img src="https://static001.geekbang.org/resource/image/fb/83/fbaac87cad19b2136e6f9f99bbc43183.png" alt="">
使用占位符方式记录slowString的方式同样需要耗时1秒是因为这种方式虽然允许我们传入Object不用拼接字符串但也只是延迟如果日志不记录那么就是省去了日志参数对象.toString()和字符串拼接的耗时。
在这个案例中除非事先判断日志级别否则必然会调用slowString方法。**回到之前提的问题,使用{}占位符语法不能通过延迟参数值获取,来解决日志数据获取的性能问题。**
除了事先判断日志级别我们还可以通过lambda表达式进行延迟参数内容获取。但SLF4J的API还不支持lambda因此需要使用Log4j2日志API把Lombok的@Slf4j注解替换为@Log4j2注解这样就可以提供一个lambda表达式作为提供参数数据的方法
```
@Log4j2
public class LoggingController {
...
log.debug(&quot;debug4:{}&quot;, ()-&gt;slowString(&quot;debug4&quot;));
```
像这样调用debug方法签名是Supplier&lt;?&gt;,参数会延迟到真正需要记录日志时再获取:
```
void debug(String message, Supplier&lt;?&gt;... paramSuppliers);
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message,
final Supplier&lt;?&gt;... paramSuppliers) {
if (isEnabled(level, marker, message)) {
logMessage(fqcn, level, marker, message, paramSuppliers);
}
}
protected void logMessage(final String fqcn, final Level level, final Marker marker, final String message,
final Supplier&lt;?&gt;... paramSuppliers) {
final Message msg = messageFactory.newMessage(message, LambdaUtil.getAll(paramSuppliers));
logMessageSafely(fqcn, level, marker, msg, msg.getThrowable());
}
```
修改后再次运行测试可以看到这次debug4并不会调用slowString方法
<img src="https://static001.geekbang.org/resource/image/6c/ab/6c44d97b24fa51ec249759cb62828aab.png" alt="">
其实我们只是换成了Log4j2 API真正的日志记录还是走的Logback框架。没错这就是SLF4J适配的一个好处。
## 重点回顾
我将记录日志的坑,总结为框架使用配置和记录本身两个方面。
Java的日志框架众多SLF4J实现了这些框架记录日志的统一。在使用SLF4J时我们需要理清楚其桥接API和绑定这两个模块。如果程序启动时出现SLF4J的错误提示那很可能是配置出现了问题可以使用Maven的dependency:tree命令梳理依赖关系。
Logback是Java最常用的日志框架其配置比较复杂你可以参考官方文档中关于Appender、Layout、Filter的配置切记不要随意从其他地方复制别人的配置避免出现错误或与当前需求不符。
使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
最后,我强调的是,日志框架提供的参数化日志记录方式不能完全取代日志级别的判断。如果你的日志量很大,获取日志参数代价也很大,就要进行相应日志级别的判断,避免不记录日志也要花费时间获取日志参数的问题。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在第一小节的案例中我们把INFO级别的日志存放到_info.log中把WARN和ERROR级别的日志存放到_error.log中。如果现在要把INFO和WARN级别的日志存放到_info.log中把ERROR日志存放到_error.log中应该如何配置Logback呢
1. 生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理,以避免单个文件太大,同时保留一定天数的历史日志,你知道如何配置吗?可以在[官方文档](http://logback.qos.ch/manual/appenders.html#RollingFileAppender)找到答案。
针对日志记录和配置,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,450 @@
<audio id="audio" title="14 | 文件IO实现高效正确的文件读写并非易事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/49/965eeca1fbb99db6f2df80c30d845649.mp3"></audio>
你好,我是朱晔。今天,我们来聊聊如何实现高效、正确的文件操作。
随着数据库系统的成熟和普及需要直接做文件IO操作的需求越来越少这就导致我们对相关API不够熟悉以至于遇到类似文件导出、三方文件对账等需求时只能临时抱佛脚随意搜索一些代码完成需求出现性能问题或者Bug后不知从何处入手。
今天这篇文章我就会从字符编码、缓冲区和文件句柄释放这3个常见问题出发和你分享如何解决与文件操作相关的性能问题或者Bug。如果你对文件操作相关的API不够熟悉可以查看[Oracle官网的介绍](https://docs.oracle.com/javase/tutorial/essential/io/)。
## 文件读写需要确保字符编码一致
有一个项目需要读取三方的对账文件定时对账,原先一直是单机处理的,没什么问题。后来为了提升性能,使用双节点同时处理对账,每一个节点处理部分对账数据,但新增的节点在处理文件中中文的时候总是读取到乱码。
程序代码都是一致的,为什么老节点就不会有问题呢?我们知道,这很可能是写代码时没有注意编码问题导致的。接下来,我们就分析下这个问题吧。
为模拟这个场景我们使用GBK编码把“你好hi”写入一个名为hello.txt的文本文件然后直接以字节数组形式读取文件内容转换为十六进制字符串输出到日志中
```
Files.deleteIfExists(Paths.get(&quot;hello.txt&quot;));
Files.write(Paths.get(&quot;hello.txt&quot;), &quot;你好hi&quot;.getBytes(Charset.forName(&quot;GBK&quot;)));
log.info(&quot;bytes:{}&quot;, Hex.encodeHexString(Files.readAllBytes(Paths.get(&quot;hello.txt&quot;))).toUpperCase());
```
输出如下:
```
13:06:28.955 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - bytes:C4E3BAC36869
```
虽然我们打开文本文件时看到的是“你好hi”但不管是什么文字计算机中都是按照一定的规则将其以二进制保存的。这个规则就是字符集字符集枚举了所有支持的字符映射成二进制的映射表。在处理文件读写的时候如果是在字节层面进行操作那么不会涉及字符编码问题而如果需要在字符层面进行读写的话就需要明确字符的编码方式也就是字符集了。
当时出现问题的文件读取代码是这样的:
```
char[] chars = new char[10];
String content = &quot;&quot;;
try (FileReader fileReader = new FileReader(&quot;hello.txt&quot;)) {
int count;
while ((count = fileReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
log.info(&quot;result:{}&quot;, content);
```
可以看到是使用了FileReader类以字符方式进行文件读取日志中读取出来的“你好”变为了乱码
```
13:06:28.961 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - result:<3A><><EFBFBD>hi
```
显然,这里并没有指定以什么字符集来读取文件中的字符。查看[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/io/FileReader.html)可以发现,**FileReader是以当前机器的默认字符集来读取文件的**如果希望指定字符集的话需要直接使用InputStreamReader和FileInputStream。
到这里我们就明白了FileReader虽然方便但因为使用了默认字符集对环境产生了依赖这就是为什么老的机器上程序可以正常运作在新节点上读取中文时却产生了乱码。
怎么确定当前机器的默认字符集呢写一段代码输出当前机器的默认字符集以及UTF-8方式编码的“你好hi”的十六进制字符串
```
log.info(&quot;charset: {}&quot;, Charset.defaultCharset());
Files.write(Paths.get(&quot;hello2.txt&quot;), &quot;你好hi&quot;.getBytes(Charsets.UTF_8));
log.info(&quot;bytes:{}&quot;, Hex.encodeHexString(Files.readAllBytes(Paths.get(&quot;hello2.txt&quot;))).toUpperCase());
```
输出结果如下:
```
13:06:28.961 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - charset: UTF-8
13:06:28.962 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - bytes:E4BDA0E5A5BD6869
```
可以看到当前机器默认字符集是UTF-8当然无法读取GBK编码的汉字。UTF-8编码的“你好”的十六进制是E4BDA0E5A5BD每一个汉字需要三个字节而GBK编码的汉字每一个汉字两个字节。字节长度都不一样以GBK编码后保存的汉字以UTF8进行解码读取必然不会成功。
定位到问题后修复就很简单了。按照文档所说直接使用FileInputStream拿文件流然后使用InputStreamReader读取字符流并指定字符集为GBK
```
private static void right1() throws IOException {
char[] chars = new char[10];
String content = &quot;&quot;;
try (FileInputStream fileInputStream = new FileInputStream(&quot;hello.txt&quot;);
InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName(&quot;GBK&quot;))) {
int count;
while ((count = inputStreamReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
log.info(&quot;result: {}&quot;, content);
}
```
从日志中看到修复后的代码正确读取到了“你好Hi”。
```
13:06:28.963 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - result: 你好hi
```
如果你觉得这种方式比较麻烦的话使用JDK1.7推出的Files类的readAllLines方法可以很方便地用一行代码完成文件内容读取
```
log.info(&quot;result: {}&quot;, Files.readAllLines(Paths.get(&quot;hello.txt&quot;), Charset.forName(&quot;GBK&quot;)).stream().findFirst().orElse(&quot;&quot;));
```
**但这种方式有个问题是读取超出内存大小的大文件时会出现OOM**。为什么呢?
打开readAllLines方法的源码可以看到readAllLines读取文件所有内容后放到一个List&lt;String&gt;中返回如果内存无法容纳这个List就会OOM
```
public static List&lt;String&gt; readAllLines(Path path, Charset cs) throws IOException {
try (BufferedReader reader = newBufferedReader(path, cs)) {
List&lt;String&gt; result = new ArrayList&lt;&gt;();
for (;;) {
String line = reader.readLine();
if (line == null)
break;
result.add(line);
}
return result;
}
}
```
那么,有没有办法实现按需的流式读取呢?比如,需要消费某行数据时再读取,而不是把整个文件一次性读取到内存?
当然有解决方案就是File类的lines方法。接下来我就与你说说使用lines方法时需要注意的一些问题。
## 使用Files类静态方法进行文件操作注意释放文件句柄
与readAllLines方法返回List&lt;String&gt;不同lines方法返回的是Stream&lt;String&gt;。这使得我们在需要时可以不断读取、使用文件中的内容而不是一次性地把所有内容都读取到内存中因此避免了OOM。
接下来我通过一段代码测试一下。我们尝试读取一个1亿1万行的文件文件占用磁盘空间超过4GB。如果使用-Xmx512m -Xms512m启动JVM控制最大堆内存为512M的话肯定无法一次性读取这样的大文件但通过Files.lines方法就没问题。
在下面的代码中首先输出这个文件的大小然后计算读取20万行数据和200万行数据的耗时差异最后逐行读取文件统计文件的总行数
```
//输出文件大小
log.info(&quot;file size:{}&quot;, Files.size(Paths.get(&quot;test.txt&quot;)));
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;read 200000 lines&quot;);
//使用Files.lines方法读取20万行数据
log.info(&quot;lines {}&quot;, Files.lines(Paths.get(&quot;test.txt&quot;)).limit(200000).collect(Collectors.toList()).size());
stopWatch.stop();
stopWatch.start(&quot;read 2000000 lines&quot;);
//使用Files.lines方法读取200万行数据
log.info(&quot;lines {}&quot;, Files.lines(Paths.get(&quot;test.txt&quot;)).limit(2000000).collect(Collectors.toList()).size());
stopWatch.stop();
log.info(stopWatch.prettyPrint());
AtomicLong atomicLong = new AtomicLong();
//使用Files.lines方法统计文件总行数
Files.lines(Paths.get(&quot;test.txt&quot;)).forEach(line-&gt;atomicLong.incrementAndGet());
log.info(&quot;total lines {}&quot;, atomicLong.get());
```
输出结果如下:
<img src="https://static001.geekbang.org/resource/image/29/77/29ee0fd687642ed87badaa96f9bdfd77.png" alt="">
可以看到实现了全文件的读取、统计了整个文件的行数并没有出现OOM读取200万行数据耗时760ms读取20万行数据仅需267ms。这些都可以说明File.lines方法并不是一次性读取整个文件的而是按需读取。
到这里,你觉得这段代码有什么问题吗?
问题在于读取完文件后没有关闭。我们通常会认为静态方法的调用不涉及资源释放因为方法调用结束自然代表资源使用完成由API释放资源但对于Files类的一些返回Stream的方法并不是这样。这是一个很容易被忽略的严重问题。
我就曾遇到过一个案例程序在生产上运行一段时间后就会出现too many files的错误我们想当然地认为是OS设置的最大文件句柄太小了就让运维放开这个限制但放开后还是会出现这样的问题。经排查发现其实是文件句柄没有释放导致的问题就出在Files.lines方法上。
我们来重现一下这个问题随便写入10行数据到一个demo.txt文件中
```
Files.write(Paths.get(&quot;demo.txt&quot;),
IntStream.rangeClosed(1, 10).mapToObj(i -&gt; UUID.randomUUID().toString()).collect(Collectors.toList())
, UTF_8, CREATE, TRUNCATE_EXISTING);
```
然后使用Files.lines方法读取这个文件100万次每读取一行计数器+1
```
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -&gt; {
try {
Files.lines(Paths.get(&quot;demo.txt&quot;)).forEach(line -&gt; longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
}
});
log.info(&quot;total : {}&quot;, longAdder.longValue());
```
运行后马上可以在日志中看到如下错误:
```
java.nio.file.FileSystemException: demo.txt: Too many open files
at sun.nio.fs.UnixException.translateToIOException(UnixException.java:91)
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)
```
使用lsof命令查看进程打开的文件可以看到打开了1万多个demo.txt
```
lsof -p 63937
...
java 63902 zhuye *238r REG 1,4 370 12934160647 /Users/zhuye/Documents/common-mistakes/demo.txt
java 63902 zhuye *239r REG 1,4 370 12934160647 /Users/zhuye/Documents/common-mistakes/demo.txt
...
lsof -p 63937 | grep demo.txt | wc -l
10007
```
**其实,在[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html)中有提到注意使用try-with-resources方式来配合确保流的close方法可以调用释放资源。**
这也很容易理解,使用流式处理,如果不显式地告诉程序什么时候用完了流,程序又如何知道呢,它也不能帮我们做主何时关闭文件。
修复方式很简单使用try来包裹Stream即可
```
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -&gt; {
try (Stream&lt;String&gt; lines = Files.lines(Paths.get(&quot;demo.txt&quot;))) {
lines.forEach(line -&gt; longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
}
});
log.info(&quot;total : {}&quot;, longAdder.longValue());
```
修改后的代码不再出现错误日志因为读取了100万次包含10行数据的文件所以最终正确输出了1000万
```
14:19:29.410 [main] INFO org.geekbang.time.commonmistakes.io.demo2.FilesStreamOperationNeedCloseApplication - total : 10000000
```
查看lines方法源码可以发现Stream的close注册了一个回调来关闭BufferedReader进行资源释放
```
public static Stream&lt;String&gt; lines(Path path, Charset cs) throws IOException {
BufferedReader br = Files.newBufferedReader(path, cs);
try {
return br.lines().onClose(asUncheckedRunnable(br));
} catch (Error|RuntimeException e) {
try {
br.close();
} catch (IOException ex) {
try {
e.addSuppressed(ex);
} catch (Throwable ignore) {}
}
throw e;
}
}
private static Runnable asUncheckedRunnable(Closeable c) {
return () -&gt; {
try {
c.close();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
```
从命名上可以看出使用BufferedReader进行字符流读取时用到了缓冲。这里缓冲Buffer的意思是使用一块内存区域作为直接操作的中转。
比如读取文件操作就是一次性读取一大块数据比如8KB到缓冲区后续的读取可以直接从缓冲区返回数据而不是每次都直接对应文件IO。写操作也是类似。如果每次写几十字节到文件都对应一次IO操作那么写一个几百兆的大文件可能就需要千万次的IO操作耗时会非常久。
接下来我就通过几个实验和你说明使用缓冲Buffer的重要性并对比下不同使用方式的文件读写性能来帮助你用对、用好Buffer。
## 注意读写文件要考虑设置缓冲区
我曾遇到过这么一个案例,一段先进行文件读入再简单处理后写入另一个文件的业务代码,由于开发人员使用了单字节的读取写入方式,导致执行得巨慢,业务量上来后需要数小时才能完成。
我们来模拟一下相关实现。创建一个文件随机写入100万行数据文件大小在35MB左右
```
Files.write(Paths.get(&quot;src.txt&quot;),
IntStream.rangeClosed(1, 1000000).mapToObj(i -&gt; UUID.randomUUID().toString()).collect(Collectors.toList())
, UTF_8, CREATE, TRUNCATE_EXISTING);
```
当时开发人员写的文件处理代码大概是这样的使用FileInputStream获得一个文件输入流然后调用其read方法每次读取一个字节最后通过一个FileOutputStream文件输出流把处理后的结果写入另一个文件。
为了简化逻辑便于理解,这里我们不对数据进行处理,直接把原文件数据写入目标文件,相当于文件复制:
```
private static void perByteOperation() throws IOException {
try (FileInputStream fileInputStream = new FileInputStream(&quot;src.txt&quot;);
FileOutputStream fileOutputStream = new FileOutputStream(&quot;dest.txt&quot;)) {
int i;
while ((i = fileInputStream.read()) != -1) {
fileOutputStream.write(i);
}
}
}
```
这样的实现复制一个35MB的文件居然耗时190秒。
**显然每读取一个字节、每写入一个字节都进行一次IO操作代价太大了**。解决方案就是,考虑使用缓冲区作为过渡,一次性从原文件读取一定数量的数据到缓冲区,一次性写入一定数量的数据到目标文件。
改良后使用100字节作为缓冲区使用FileInputStream的byte[]的重载来一次性读取一定字节的数据同时使用FileOutputStream的byte[]的重载实现一次性从缓冲区写入一定字节的数据到文件:
```
private static void bufferOperationWith100Buffer() throws IOException {
try (FileInputStream fileInputStream = new FileInputStream(&quot;src.txt&quot;);
FileOutputStream fileOutputStream = new FileOutputStream(&quot;dest.txt&quot;)) {
byte[] buffer = new byte[100];
int len = 0;
while ((len = fileInputStream.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, len);
}
}
}
```
仅仅使用了100个字节的缓冲区作为过渡完成35M文件的复制耗时缩短到了26秒是无缓冲时性能的7倍如果把缓冲区放大到1000字节耗时可以进一步缩短到342毫秒。可以看到**在进行文件IO处理的时候使用合适的缓冲区可以明显提高性能**。
你可能会说实现文件读写还要自己new一个缓冲区出来太麻烦了不是有一个BufferedInputStream和BufferedOutputStream可以实现输入输出流的缓冲处理吗
是的它们在内部实现了一个默认8KB大小的缓冲区。但是在使用BufferedInputStream和BufferedOutputStream时我还是建议你再使用一个缓冲进行读写不要因为它们实现了内部缓冲就进行逐字节的操作。
接下来,我写一段代码比较下使用下面三种方式读写一个字节的性能:
- 直接使用BufferedInputStream和BufferedOutputStream
- 额外使用一个8KB缓冲使用BufferedInputStream和BufferedOutputStream
- 直接使用FileInputStream和FileOutputStream再使用一个8KB的缓冲。
```
//使用BufferedInputStream和BufferedOutputStream
private static void bufferedStreamByteOperation() throws IOException {
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(&quot;src.txt&quot;));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(&quot;dest.txt&quot;))) {
int i;
while ((i = bufferedInputStream.read()) != -1) {
bufferedOutputStream.write(i);
}
}
}
//额外使用一个8KB缓冲再使用BufferedInputStream和BufferedOutputStream
private static void bufferedStreamBufferOperation() throws IOException {
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(&quot;src.txt&quot;));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(&quot;dest.txt&quot;))) {
byte[] buffer = new byte[8192];
int len = 0;
while ((len = bufferedInputStream.read(buffer)) != -1) {
bufferedOutputStream.write(buffer, 0, len);
}
}
}
//直接使用FileInputStream和FileOutputStream再使用一个8KB的缓冲
private static void largerBufferOperation() throws IOException {
try (FileInputStream fileInputStream = new FileInputStream(&quot;src.txt&quot;);
FileOutputStream fileOutputStream = new FileOutputStream(&quot;dest.txt&quot;)) {
byte[] buffer = new byte[8192];
int len = 0;
while ((len = fileInputStream.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, len);
}
}
}
```
结果如下:
```
---------------------------------------------
ns % Task name
---------------------------------------------
1424649223 086% bufferedStreamByteOperation
117807808 007% bufferedStreamBufferOperation
112153174 007% largerBufferOperation
```
可以看到第一种方式虽然使用了缓冲流但逐字节的操作因为方法调用次数实在太多还是慢耗时1.4秒后面两种方式的性能差不多耗时110毫秒左右。虽然第三种方式没有使用缓冲流但使用了8KB大小的缓冲区和缓冲流默认的缓冲区大小相同。
看到这里你可能会疑惑了既然这样使用BufferedInputStream和BufferedOutputStream有什么意义呢
其实这里我是为了演示所以示例三使用了固定大小的缓冲区但在实际代码中每次需要读取的字节数很可能不是固定的有的时候读取几个字节有的时候读取几百字节这个时候有一个固定大小较大的缓冲也就是使用BufferedInputStream和BufferedOutputStream做为后备的稳定的二次缓冲就非常有意义了。
最后我要补充说明的是对于类似的文件复制操作如果希望有更高性能可以使用FileChannel的transfreTo方法进行流的复制。在一些操作系统比如高版本的Linux和UNIX上可以实现DMA直接内存访问也就是数据从磁盘经过总线直接发送到目标文件无需经过内存和CPU进行数据中转
```
private static void fileChannelOperation() throws IOException {
FileChannel in = FileChannel.open(Paths.get(&quot;src.txt&quot;), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get(&quot;dest.txt&quot;), CREATE, WRITE);
in.transferTo(0, in.size(), out);
}
```
你可以通过[这篇文章](https://developer.ibm.com/articles/j-zerocopy/)了解transferTo方法的更多细节。
在测试FileChannel性能的同时我再运行一下这一小节中的所有实现比较一下读写35MB文件的耗时。
```
---------------------------------------------
ns % Task name
---------------------------------------------
183673362265 098% perByteOperation
2034504694 001% bufferOperationWith100Buffer
749967898 000% bufferedStreamByteOperation
110602155 000% bufferedStreamBufferOperation
114542834 000% largerBufferOperation
050068602 000% fileChannelOperation
```
可以看到最慢的是单字节读写文件流的方式耗时183秒最快的是FileChannel.transferTo方式进行流转发的方式耗时50毫秒。两者耗时相差达到3600倍
## 重点回顾
今天,我通过三个案例和你分享了文件读写操作中最重要的几个方面。
第一,如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致的,否则可能产生乱码。
第二使用Files类的一些流式处理操作注意使用try-with-resources包装Stream确保底层文件资源可以释放避免产生too many open files的问题。
第三进行文件字节流操作的时候一般情况下不考虑进行逐字节操作使用缓冲区进行批量读写减少IO次数性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream追求极限性能的话可以考虑使用FileChannel进行流转发。
最后我要强调的是文件操作因为涉及操作系统和文件系统的实现JDK并不能确保所有IO API在所有平台的逻辑一致性代码迁移到新的操作系统或文件系统时要重新进行功能测试和性能测试。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. Files.lines方法进行流式处理需要使用try-with-resources进行资源释放。那么使用Files类中其他返回Stream包装对象的方法进行流式处理比如newDirectoryStream方法返回DirectoryStream&lt;Path&gt;list、walk和find方法返回Stream&lt;Path&gt;,也同样有资源释放问题吗?
1. Java的File类和Files类提供的文件复制、重命名、删除等操作是原子性的吗
对于文件操作,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,773 @@
<audio id="audio" title="15 | 序列化:一来一回你还是原来的你吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/f6/f22ac73a565ee8dfcb28a99f300629f6.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊序列化相关的坑和最佳实践。
序列化是把对象转换为字节流的过程,以方便传输或存储。反序列化,则是反过来把字节流转换为对象的过程。在介绍[文件IO](https://time.geekbang.org/column/article/223051)的时候,我提到字符编码是把字符转换为二进制的过程,至于怎么转换需要由字符集制定规则。同样地,对象的序列化和反序列化,也需要由序列化算法制定规则。
关于序列化算法几年前常用的有JDKJava序列化、XML序列化等但前者不能跨语言后者性能较差时间空间开销大现在RESTful应用最常用的是JSON序列化追求性能的RPC框架比如gRPC使用protobuf序列化这2种方法都是跨语言的而且性能不错应用广泛。
在架构设计阶段我们可能会重点关注算法选型在性能、易用性和跨平台性等中权衡不过这里的坑比较少。通常情况下序列化问题常见的坑会集中在业务场景中比如Redis、参数和响应序列化反序列化。
今天,我们就一起聊聊开发中序列化常见的一些坑吧。
## 序列化和反序列化需要确保算法一致
业务代码中涉及序列化时很重要的一点是要确保序列化和反序列化的算法一致性。有一次我要排查缓存命中率问题需要运维同学帮忙拉取Redis中的Key结果他反馈Redis中存的都是乱码怀疑Redis被攻击了。其实呢这个问题就是序列化算法导致的我们来看下吧。
在这个案例中开发同学使用RedisTemplate来操作Redis进行数据缓存。因为相比于Jedis使用Spring提供的RedisTemplate操作Redis除了无需考虑连接池、更方便外还可以与Spring Cache等其他组件无缝整合。如果使用Spring Boot的话无需任何配置就可以直接使用。
数据包含Key和Value要保存到Redis需要经过序列化算法来序列化成字符串。虽然Redis支持多种数据结构比如Hash但其每一个field的Value还是字符串。如果Value本身也是字符串的话能否有便捷的方式来使用RedisTemplate而无需考虑序列化呢
其实是有的那就是StringRedisTemplate。
那StringRedisTemplate和RedisTemplate的区别是什么呢开头提到的乱码又是怎么回事呢带着这些问题让我们来研究一下吧。
写一段测试代码在应用初始化完成后向Redis设置两组数据第一次使用RedisTemplate设置Key为redisTemplate、Value为User对象第二次使用StringRedisTemplate设置Key为stringRedisTemplate、Value为JSON序列化后的User对象
```
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ObjectMapper objectMapper;
@PostConstruct
public void init() throws JsonProcessingException {
redisTemplate.opsForValue().set(&quot;redisTemplate&quot;, new User(&quot;zhuye&quot;, 36));
stringRedisTemplate.opsForValue().set(&quot;stringRedisTemplate&quot;, objectMapper.writeValueAsString(new User(&quot;zhuye&quot;, 36)));
}
```
如果你认为StringRedisTemplate和RedisTemplate的区别无非是读取的Value是String和Object那就大错特错了因为使用这两种方式存取的数据完全无法通用。
我们做个小实验通过RedisTemplate读取Key为stringRedisTemplate的Value使用StringRedisTemplate读取Key为redisTemplate的Value
```
log.info(&quot;redisTemplate get {}&quot;, redisTemplate.opsForValue().get(&quot;stringRedisTemplate&quot;));
log.info(&quot;stringRedisTemplate get {}&quot;, stringRedisTemplate.opsForValue().get(&quot;redisTemplate&quot;));
```
结果是两次都无法读取到Value
```
[11:49:38.478] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:38 ] - redisTemplate get null
[11:49:38.481] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:39 ] - stringRedisTemplate get null
```
通过redis-cli客户端工具连接到Redis你会发现根本就没有叫作redisTemplate的Key所以StringRedisTemplate无法查到数据
<img src="https://static001.geekbang.org/resource/image/0a/35/0a86608821f52833e3ffaecb69945635.png" alt="">
查看RedisTemplate的源码发现默认情况下RedisTemplate针对Key和Value使用了JDK序列化
```
public void afterPropertiesSet() {
...
if (defaultSerializer == null) {
defaultSerializer = new JdkSerializationRedisSerializer(
classLoader != null ? classLoader : this.getClass().getClassLoader());
}
if (enableDefaultSerializer) {
if (keySerializer == null) {
keySerializer = defaultSerializer;
defaultUsed = true;
}
if (valueSerializer == null) {
valueSerializer = defaultSerializer;
defaultUsed = true;
}
if (hashKeySerializer == null) {
hashKeySerializer = defaultSerializer;
defaultUsed = true;
}
if (hashValueSerializer == null) {
hashValueSerializer = defaultSerializer;
defaultUsed = true;
}
}
...
}
```
**redis-cli看到的类似一串乱码的"\xac\xed\x00\x05t\x00\rredisTemplate"字符串其实就是字符串redisTemplate经过JDK序列化后的结果**。这就回答了之前提到的乱码问题。而RedisTemplate尝试读取Key为stringRedisTemplate数据时也会对这个字符串进行JDK序列化处理所以同样无法读取到数据。
而StringRedisTemplate对于Key和Value使用的是String序列化方式Key和Value只能是String
```
public class StringRedisTemplate extends RedisTemplate&lt;String, String&gt; {
public StringRedisTemplate() {
setKeySerializer(RedisSerializer.string());
setValueSerializer(RedisSerializer.string());
setHashKeySerializer(RedisSerializer.string());
setHashValueSerializer(RedisSerializer.string());
}
}
public class StringRedisSerializer implements RedisSerializer&lt;String&gt; {
@Override
public String deserialize(@Nullable byte[] bytes) {
return (bytes == null ? null : new String(bytes, charset));
}
@Override
public byte[] serialize(@Nullable String string) {
return (string == null ? null : string.getBytes(charset));
}
}
```
看到这里我们应该知道RedisTemplate和StringRedisTemplate保存的数据无法通用。修复方式就是让它们读取自己存的数据
- 使用RedisTemplate读出的数据由于是Object类型的使用时可以先强制转换为User类型
- 使用StringRedisTemplate读取出的字符串需要手动将JSON反序列化为User类型。
```
//使用RedisTemplate获取Value无需反序列化就可以拿到实际对象虽然方便但是Redis中保存的Key和Value不易读
User userFromRedisTemplate = (User) redisTemplate.opsForValue().get(&quot;redisTemplate&quot;);
log.info(&quot;redisTemplate get {}&quot;, userFromRedisTemplate);
//使用StringRedisTemplate虽然Key正常但是Value存取需要手动序列化成字符串
User userFromStringRedisTemplate = objectMapper.readValue(stringRedisTemplate.opsForValue().get(&quot;stringRedisTemplate&quot;), User.class);
log.info(&quot;stringRedisTemplate get {}&quot;, userFromStringRedisTemplate);
```
这样就可以得到正确输出:
```
[13:32:09.087] [http-nio-45678-exec-6] [INFO ] [.t.c.s.demo1.RedisTemplateController:45 ] - redisTemplate get User(name=zhuye, age=36)
[13:32:09.092] [http-nio-45678-exec-6] [INFO ] [.t.c.s.demo1.RedisTemplateController:47 ] - stringRedisTemplate get User(name=zhuye, age=36)
```
看到这里你可能会说使用RedisTemplate获取Value虽然方便但是Key和Value不易读而使用StringRedisTemplate虽然Key是普通字符串但是Value存取需要手动序列化成字符串有没有两全其美的方式呢
当然有自己定义RedisTemplate的Key和Value的序列化方式即可Key的序列化使用RedisSerializer.string()也就是StringRedisSerializer方式实现字符串序列化而Value的序列化使用Jackson2JsonRedisSerializer
```
@Bean
public &lt;T&gt; RedisTemplate&lt;String, T&gt; redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate&lt;String, T&gt; redisTemplate = new RedisTemplate&lt;&gt;();
redisTemplate.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
```
写代码测试一下存取直接注入类型为RedisTemplate&lt;String, User&gt;的userRedisTemplate字段然后在right2方法中使用注入的userRedisTemplate存入一个User对象再分别使用userRedisTemplate和StringRedisTemplate取出这个对象
```
@Autowired
private RedisTemplate&lt;String, User&gt; userRedisTemplate;
@GetMapping(&quot;right2&quot;)
public void right2() {
User user = new User(&quot;zhuye&quot;, 36);
userRedisTemplate.opsForValue().set(user.getName(), user);
Object userFromRedis = userRedisTemplate.opsForValue().get(user.getName());
log.info(&quot;userRedisTemplate get {} {}&quot;, userFromRedis, userFromRedis.getClass());
log.info(&quot;stringRedisTemplate get {}&quot;, stringRedisTemplate.opsForValue().get(user.getName()));
}
```
乍一看没啥问题StringRedisTemplate成功查出了我们存入的数据
```
[14:07:41.315] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:55 ] - userRedisTemplate get {name=zhuye, age=36} class java.util.LinkedHashMap
[14:07:41.318] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:56 ] - stringRedisTemplate get {&quot;name&quot;:&quot;zhuye&quot;,&quot;age&quot;:36}
```
Redis里也可以查到Key是纯字符串Value是JSON序列化后的User对象
<img src="https://static001.geekbang.org/resource/image/ac/cc/ac20bd2117053fafee390bbb6ce1eccc.png" alt="">
但值得注意的是,这里有一个坑。**第一行的日志输出显示userRedisTemplate获取到的Value是LinkedHashMap类型的**完全不是泛型的RedisTemplate设置的User类型。
如果我们把代码里从Redis中获取到的Value变量类型由Object改为User编译不会出现问题但会出现ClassCastException
```
java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to org.geekbang.time.commonmistakes.serialization.demo1.User
```
修复方式是修改自定义RestTemplate的代码把new出来的Jackson2JsonRedisSerializer设置一个自定义的ObjectMapper启用activateDefaultTyping方法把类型信息作为属性写入序列化后的数据中当然了你也可以调整JsonTypeInfo.As枚举以其他形式保存类型信息
```
...
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
//把类型信息作为属性写入Value
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
...
```
或者直接使用RedisSerializer.json()快捷方法它内部使用的GenericJackson2JsonRedisSerializer直接设置了把类型作为属性保存到Value中
```
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setHashValueSerializer(RedisSerializer.json());
```
重启程序调用right2方法进行测试可以看到从自定义的RedisTemplate中获取到的Value是User类型的第一行日志而且Redis中实际保存的Value包含了类型完全限定名第二行日志
```
[15:10:50.396] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:55 ] - userRedisTemplate get User(name=zhuye, age=36) class org.geekbang.time.commonmistakes.serialization.demo1.User
[15:10:50.399] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:56 ] - stringRedisTemplate get [&quot;org.geekbang.time.commonmistakes.serialization.demo1.User&quot;,{&quot;name&quot;:&quot;zhuye&quot;,&quot;age&quot;:36}]
```
因此反序列化时可以直接得到User类型的Value。
通过对RedisTemplate组件的分析可以看到当数据需要序列化后保存时读写数据使用一致的序列化算法的必要性否则就像对牛弹琴。
这里我再总结下Spring提供的4种RedisSerializerRedis序列化器
- 默认情况下RedisTemplate使用JdkSerializationRedisSerializer也就是JDK序列化容易产生Redis中保存了乱码的错觉。
- 通常考虑到易读性可以设置Key的序列化器为StringRedisSerializer。但直接使用RedisSerializer.string()相当于使用了UTF_8编码的StringRedisSerializer需要注意字符集问题。
- 如果希望Value也是使用JSON序列化的话可以把Value序列化器设置为Jackson2JsonRedisSerializer。默认情况下不会把类型信息保存在Value中即使我们定义RedisTemplate的Value泛型为实际类型查询出的Value也只能是LinkedHashMap类型。如果希望直接获取真实的数据类型你可以启用Jackson ObjectMapper的activateDefaultTyping方法把类型信息一起序列化保存在Value中。
- 如果希望Value以JSON保存并带上类型信息更简单的方式是直接使用RedisSerializer.json()快捷方法来获取序列化器。
## 注意Jackson JSON反序列化对额外字段的处理
前面我提到通过设置JSON序列化工具Jackson的activateDefaultTyping方法可以在序列化数据时写入对象类型。其实Jackson还有很多参数可以控制序列化和反序列化是一个功能强大而完善的序列化工具。因此很多框架都将Jackson作为JDK序列化工具比如Spring Web。但也正是这个原因我们使用时要小心各个参数的配置。
比如在开发Spring Web应用程序时如果自定义了ObjectMapper并把它注册成了Bean那很可能会导致Spring Web使用的ObjectMapper也被替换导致Bug。
我们来看一个案例。程序一开始是正常的某一天开发同学希望修改一下ObjectMapper的行为让枚举序列化为索引值而不是字符串值比如默认情况下序列化一个Color枚举中的Color.BLUE会得到字符串BLUE
```
@Autowired
private ObjectMapper objectMapper;
@GetMapping(&quot;test&quot;)
public void test() throws JsonProcessingException {
log.info(&quot;color:{}&quot;, objectMapper.writeValueAsString(Color.BLUE));
}
enum Color {
RED, BLUE
}
```
于是这位同学就重新定义了一个ObjectMapper Bean开启了WRITE_ENUMS_USING_INDEX功能特性
```
@Bean
public ObjectMapper objectMapper(){
ObjectMapper objectMapper=new ObjectMapper();
objectMapper.configure(SerializationFeature.WRITE_ENUMS_USING_INDEX,true);
return objectMapper;
}
```
开启这个特性后Color.BLUE枚举序列化成索引值1
```
[16:11:37.382] [http-nio-45678-exec-1] [INFO ] [c.s.d.JsonIgnorePropertiesController:19 ] - color:1
```
修改后处理枚举序列化的逻辑是满足了要求但线上爆出了大量400错误日志中也出现了很多UnrecognizedPropertyException
```
JSON parse error: Unrecognized field \&quot;ver\&quot; (class org.geekbang.time.commonmistakes.serialization.demo4.UserWrong), not marked as ignorable; nested exception is com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field \&quot;version\&quot; (class org.geekbang.time.commonmistakes.serialization.demo4.UserWrong), not marked as ignorable (one known property: \&quot;name\&quot;])\n at [Source: (PushbackInputStream); line: 1, column: 22] (through reference chain: org.geekbang.time.commonmistakes.serialization.demo4.UserWrong[\&quot;ver\&quot;])
```
从异常信息中可以看到这是因为反序列化的时候原始数据多了一个version属性。进一步分析发现我们使用了UserWrong类型作为Web控制器wrong方法的入参其中只有一个name属性
```
@Data
public class UserWrong {
private String name;
}
@PostMapping(&quot;wrong&quot;)
public UserWrong wrong(@RequestBody UserWrong user) {
return user;
}
```
而客户端实际传过来的数据多了一个version属性。那为什么之前没这个问题呢
问题就出在,**自定义ObjectMapper启用WRITE_ENUMS_USING_INDEX序列化功能特性时覆盖了Spring Boot自动创建的ObjectMapper**而这个自动创建的ObjectMapper设置过FAIL_ON_UNKNOWN_PROPERTIES反序列化特性为false以确保出现未知字段时不要抛出异常。源码如下
```
public MappingJackson2HttpMessageConverter() {
this(Jackson2ObjectMapperBuilder.json().build());
}
public class Jackson2ObjectMapperBuilder {
...
private void customizeDefaultFeatures(ObjectMapper objectMapper) {
if (!this.features.containsKey(MapperFeature.DEFAULT_VIEW_INCLUSION)) {
configureFeature(objectMapper, MapperFeature.DEFAULT_VIEW_INCLUSION, false);
}
if (!this.features.containsKey(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) {
configureFeature(objectMapper, DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
}
}
```
要修复这个问题,有三种方式:
- 第一种同样禁用自定义的ObjectMapper的FAIL_ON_UNKNOWN_PROPERTIES
```
@Bean
public ObjectMapper objectMapper(){
ObjectMapper objectMapper=new ObjectMapper();
objectMapper.configure(SerializationFeature.WRITE_ENUMS_USING_INDEX,true);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
return objectMapper;
}
```
- 第二种,设置自定义类型,加上@JsonIgnoreProperties注解开启ignoreUnknown属性以实现反序列化时忽略额外的数据
```
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserRight {
private String name;
}
```
- 第三种不要自定义ObjectMapper而是直接在配置文件设置相关参数来修改Spring默认的ObjectMapper的功能。比如直接在配置文件启用把枚举序列化为索引号
```
spring.jackson.serialization.write_enums_using_index=true
```
或者可以直接定义Jackson2ObjectMapperBuilderCustomizer Bean来启用新特性
```
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer(){
return builder -&gt; builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_INDEX);
}
```
这个案例告诉我们两点:
- Jackson针对序列化和反序列化有大量的细节功能特性我们可以参考Jackson官方文档来了解这些特性详见[SerializationFeature](https://fasterxml.github.io/jackson-databind/javadoc/2.10/com/fasterxml/jackson/databind/SerializationFeature.html)、[DeserializationFeature](https://fasterxml.github.io/jackson-databind/javadoc/2.10/com/fasterxml/jackson/databind/DeserializationFeature.html)和[MapperFeature](https://fasterxml.github.io/jackson-databind/javadoc/2.10/com/fasterxml/jackson/databind/MapperFeature.html)。
- 忽略多余字段是我们写业务代码时最容易遇到的一个配置项。Spring Boot在自动配置时贴心地做了全局设置。如果需要设置更多的特性可以直接修改配置文件spring.jackson.**或设置Jackson2ObjectMapperBuilderCustomizer回调接口来启用更多设置无需重新定义ObjectMapper Bean。
## 反序列化时要小心类的构造方法
使用Jackson反序列化时除了要注意忽略额外字段的问题外还要小心类的构造方法。我们看一个实际的踩坑案例吧。
有一个APIResult类包装了REST接口的返回体作为Web控制器的出参其中boolean类型的success字段代表是否处理成功、int类型的code字段代表处理状态码。
开始时在返回APIResult的时候每次都根据code来设置success。如果code是2000那么success是true否则是false。后来为了减少重复代码把这个逻辑放到了APIResult类的构造方法中处理
```
@Data
public class APIResultWrong {
private boolean success;
private int code;
public APIResultWrong() {
}
public APIResultWrong(int code) {
this.code = code;
if (code == 2000) success = true;
else success = false;
}
}
```
经过改动后发现即使code为2000返回APIResult的success也是false。比如我们反序列化两次APIResult一次使用code==1234一次使用code==2000
```
@Autowired
ObjectMapper objectMapper;
@GetMapping(&quot;wrong&quot;)
public void wrong() throws JsonProcessingException {
log.info(&quot;result :{}&quot;, objectMapper.readValue(&quot;{\&quot;code\&quot;:1234}&quot;, APIResultWrong.class));
log.info(&quot;result :{}&quot;, objectMapper.readValue(&quot;{\&quot;code\&quot;:2000}&quot;, APIResultWrong.class));
}
```
日志输出如下:
```
[17:36:14.591] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:20 ] - result :APIResultWrong(success=false, code=1234)
[17:36:14.591] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:21 ] - result :APIResultWrong(success=false, code=2000)
```
可以看到两次的APIResult的success字段都是false。
出现这个问题的原因是,**默认情况下在反序列化的时候Jackson框架只会调用无参构造方法创建对象**。如果走自定义的构造方法创建对象,需要通过@JsonCreator来指定构造方法,并通过@JsonProperty设置构造方法中参数对应的JSON属性名
```
@Data
public class APIResultRight {
...
@JsonCreator
public APIResultRight(@JsonProperty(&quot;code&quot;) int code) {
this.code = code;
if (code == 2000) success = true;
else success = false;
}
}
```
重新运行程序,可以得到正确输出:
```
[17:41:23.188] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:26 ] - result :APIResultRight(success=false, code=1234)
[17:41:23.188] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:27 ] - result :APIResultRight(success=true, code=2000)
```
可以看到这次传入code==2000时success可以设置为true。
## 枚举作为API接口参数或返回值的两个大坑
在前面的例子中我演示了如何把枚举序列化为索引值。但对于枚举我建议尽量在程序内部使用而不是作为API接口的参数或返回值原因是枚举涉及序列化和反序列化时会有两个大坑。
**第一个坑是,客户端和服务端的枚举定义不一致时,会出异常。**比如客户端版本的枚举定义了4个枚举值
```
@Getter
enum StatusEnumClient {
CREATED(1, &quot;已创建&quot;),
PAID(2, &quot;已支付&quot;),
DELIVERED(3, &quot;已送到&quot;),
FINISHED(4, &quot;已完成&quot;);
private final int status;
private final String desc;
StatusEnumClient(Integer status, String desc) {
this.status = status;
this.desc = desc;
}
}
```
服务端定义了5个枚举值
```
@Getter
enum StatusEnumServer {
...
CANCELED(5, &quot;已取消&quot;);
private final int status;
private final String desc;
StatusEnumServer(Integer status, String desc) {
this.status = status;
this.desc = desc;
}
}
```
写代码测试一下使用RestTemplate来发起请求让服务端返回客户端不存在的枚举值
```
@GetMapping(&quot;getOrderStatusClient&quot;)
public void getOrderStatusClient() {
StatusEnumClient result = restTemplate.getForObject(&quot;http://localhost:45678/enumusedinapi/getOrderStatus&quot;, StatusEnumClient.class);
log.info(&quot;result {}&quot;, result);
}
@GetMapping(&quot;getOrderStatus&quot;)
public StatusEnumServer getOrderStatus() {
return StatusEnumServer.CANCELED;
}
```
访问接口会出现如下异常信息提示在枚举StatusEnumClient中找不到CANCELED
```
JSON parse error: Cannot deserialize value of type `org.geekbang.time.commonmistakes.enums.enumusedinapi.StatusEnumClient` from String &quot;CANCELED&quot;: not one of the values accepted for Enum class: [CREATED, FINISHED, DELIVERED, PAID];
```
要解决这个问题可以开启Jackson的read_unknown_enum_values_using_default_value反序列化特性也就是在枚举值未知的时候使用默认值
```
spring.jackson.deserialization.read_unknown_enum_values_using_default_value=true
```
并为枚举添加一个默认值,使用@JsonEnumDefaultValue注解注释
```
@JsonEnumDefaultValue
UNKNOWN(-1, &quot;未知&quot;);
```
需要注意的是这个枚举值一定是添加在客户端StatusEnumClient中的因为反序列化使用的是客户端枚举。
这里还有一个小坑是仅仅这样配置还不能让RestTemplate生效这个反序列化特性还需要配置RestTemplate来使用Spring Boot的MappingJackson2HttpMessageConverter才行
```
@Bean
public RestTemplate restTemplate(MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) {
return new RestTemplateBuilder()
.additionalMessageConverters(mappingJackson2HttpMessageConverter)
.build();
}
```
现在,请求接口可以返回默认值了:
```
[21:49:03.887] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:25 ] - result UNKNOWN
```
**第二个坑也是更大的坑枚举序列化反序列化实现自定义的字段非常麻烦会涉及Jackson的Bug**。比如下面这个接口传入枚举List为List增加一个CENCELED枚举值然后返回
```
@PostMapping(&quot;queryOrdersByStatusList&quot;)
public List&lt;StatusEnumServer&gt; queryOrdersByStatus(@RequestBody List&lt;StatusEnumServer&gt; enumServers) {
enumServers.add(StatusEnumServer.CANCELED);
return enumServers;
}
```
如果我们希望根据枚举的Desc字段来序列化传入“已送到”作为入参
<img src="https://static001.geekbang.org/resource/image/50/26/50a81c64fef9f9f3a8f57bccaaad5226.png" alt="">
会得到异常,提示“已送到”不是正确的枚举值:
```
JSON parse error: Cannot deserialize value of type `org.geekbang.time.commonmistakes.enums.enumusedinapi.StatusEnumServer` from String &quot;已送到&quot;: not one of the values accepted for Enum class: [CREATED, CANCELED, FINISHED, DELIVERED, PAID]
```
显然这里反序列化使用的是枚举的name序列化也是一样
<img src="https://static001.geekbang.org/resource/image/04/43/0455db08f97feb2382be6e3c8329da43.png" alt="">
你可能也知道要让枚举的序列化和反序列化走desc字段可以在字段上加@JsonValue注解修改StatusEnumServer和StatusEnumClient
```
@JsonValue
private final String desc;
```
然后再尝试下果然可以用desc作为入参了而且出参也使用了枚举的desc
<img src="https://static001.geekbang.org/resource/image/08/2b/08acd86f0cabd174d45f6319bffa802b.png" alt="">
但是,如果你认为这样就完美解决问题了,那就大错特错了。你可以再尝试把@JsonValue注解加在int类型的status字段上也就是希望序列化反序列化走status字段
```
@JsonValue
private final int status;
```
写一个客户端测试一下传入CREATED和PAID两个枚举值
```
@GetMapping(&quot;queryOrdersByStatusListClient&quot;)
public void queryOrdersByStatusListClient() {
List&lt;StatusEnumClient&gt; request = Arrays.asList(StatusEnumClient.CREATED, StatusEnumClient.PAID);
HttpEntity&lt;List&lt;StatusEnumClient&gt;&gt; entity = new HttpEntity&lt;&gt;(request, new HttpHeaders());
List&lt;StatusEnumClient&gt; response = restTemplate.exchange(&quot;http://localhost:45678/enumusedinapi/queryOrdersByStatusList&quot;,
HttpMethod.POST, entity, new ParameterizedTypeReference&lt;List&lt;StatusEnumClient&gt;&gt;() {}).getBody();
log.info(&quot;result {}&quot;, response);
}
```
请求接口可以看到传入的是CREATED和PAID返回的居然是DELIVERED和FINISHED。果然如标题所说一来一回你已不是原来的你
```
[22:03:03.579] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:34 ] - result [DELIVERED, FINISHED, UNKNOWN]
```
出现这个问题的原因是,**序列化走了status的值而反序列化并没有根据status来还是使用了枚举的ordinal()索引值**。这是Jackson[至今2.10没有解决的Bug](https://github.com/FasterXML/jackson-databind/issues/1850)应该会在2.11解决。
如下图所示我们调用服务端接口传入一个不存在的status值0也能反序列化成功最后服务端的返回是1
<img src="https://static001.geekbang.org/resource/image/ac/bd/ac6cd2c0957d1654fced53e99eb556bd.png" alt="">
有一个解决办法是,设置@JsonCreator来强制反序列化时使用自定义的工厂方法可以实现使用枚举的status字段来取值。我们把这段代码加在StatusEnumServer枚举类中
```
@JsonCreator
public static StatusEnumServer parse(Object o) {
return Arrays.stream(StatusEnumServer.values()).filter(value-&gt;o.equals(value.status)).findFirst().orElse(null);
}
```
要特别注意的是我们同样要为StatusEnumClient也添加相应的方法。因为除了服务端接口接收StatusEnumServer参数涉及一次反序列化外从服务端返回值转换为List还会有一次反序列化
```
@JsonCreator
public static StatusEnumClient parse(Object o) {
return Arrays.stream(StatusEnumClient.values()).filter(value-&gt;o.equals(value.status)).findFirst().orElse(null);
}
```
重新调用接口发现虽然结果正确了但是服务端不存在的枚举值CANCELED被设置为了null而不是@JsonEnumDefaultValue设置的UNKNOWN
这个问题,我们之前已经通过设置@JsonEnumDefaultValue注解解决了,但现在又出现了:
```
[22:20:13.727] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:34 ] - result [CREATED, PAID, null]
```
原因也很简单我们自定义的parse方法实现的是找不到枚举值时返回null。
为彻底解决这个问题,并避免通过@JsonCreator在枚举中自定义一个非常复杂的工厂方法,我们可以实现一个自定义的反序列化器。这段代码比较复杂,我特意加了详细的注释:
```
class EnumDeserializer extends JsonDeserializer&lt;Enum&gt; implements
ContextualDeserializer {
private Class&lt;Enum&gt; targetClass;
public EnumDeserializer() {
}
public EnumDeserializer(Class&lt;Enum&gt; targetClass) {
this.targetClass = targetClass;
}
@Override
public Enum deserialize(JsonParser p, DeserializationContext ctxt) {
//找枚举中带有@JsonValue注解的字段这是我们反序列化的基准字段
Optional&lt;Field&gt; valueFieldOpt = Arrays.asList(targetClass.getDeclaredFields()).stream()
.filter(m -&gt; m.isAnnotationPresent(JsonValue.class))
.findFirst();
if (valueFieldOpt.isPresent()) {
Field valueField = valueFieldOpt.get();
if (!valueField.isAccessible()) {
valueField.setAccessible(true);
}
//遍历枚举项,查找字段的值等于反序列化的字符串的那个枚举项
return Arrays.stream(targetClass.getEnumConstants()).filter(e -&gt; {
try {
return valueField.get(e).toString().equals(p.getValueAsString());
} catch (Exception ex) {
ex.printStackTrace();
}
return false;
}).findFirst().orElseGet(() -&gt; Arrays.stream(targetClass.getEnumConstants()).filter(e -&gt; {
//如果找不到,就需要寻找默认枚举值来替代,同样遍历所有枚举项,查找@JsonEnumDefaultValue注解标识的枚举项
try {
return targetClass.getField(e.name()).isAnnotationPresent(JsonEnumDefaultValue.class);
} catch (Exception ex) {
ex.printStackTrace();
}
return false;
}).findFirst().orElse(null));
}
return null;
}
@Override
public JsonDeserializer&lt;?&gt; createContextual(DeserializationContext ctxt,
BeanProperty property) throws JsonMappingException {
targetClass = (Class&lt;Enum&gt;) ctxt.getContextualType().getRawClass();
return new EnumDeserializer(targetClass);
}
}
```
然后把这个自定义反序列化器注册到Jackson中
```
@Bean
public Module enumModule() {
SimpleModule module = new SimpleModule();
module.addDeserializer(Enum.class, new EnumDeserializer());
return module;
}
```
第二个大坑终于被完美地解决了:
```
[22:32:28.327] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:34 ] - result [CREATED, PAID, UNKNOWN]
```
这样做虽然解决了序列化反序列化使用枚举中自定义字段的问题也解决了找不到枚举值时使用默认值的问题但解决方案很复杂。因此我还是建议在DTO中直接使用int或String等简单的数据类型而不是使用枚举再配合各种复杂的序列化配置来实现枚举到枚举中字段的映射会更加清晰明了。
## 重点回顾
今天我基于Redis和Web API的入参和出参两个场景和你介绍了序列化和反序列化时需要避开的几个坑。
第一,要确保序列化和反序列化算法的一致性。因为,不同序列化算法输出必定不同,要正确处理序列化后的数据就要使用相同的反序列化算法。
第二Jackson有大量的序列化和反序列化特性可以用来微调序列化和反序列化的细节。需要注意的是如果自定义ObjectMapper的Bean小心不要和Spring Boot自动配置的Bean冲突。
第三,在调试序列化反序列化问题时,我们一定要捋清楚三点:是哪个组件在做序列化反序列化、整个过程有几次序列化反序列化,以及目前到底是序列化还是反序列化。
第四对于反序列化默认情况下框架调用的是无参构造方法如果要调用自定义的有参构造方法那么需要告知框架如何调用。更合理的方式是对于需要序列化的POJO考虑尽量不要自定义构造方法。
第五枚举不建议定义在DTO中跨服务传输因为会有版本问题并且涉及序列化反序列化时会很复杂容易出错。因此我只建议在程序内部使用枚举。
最后还有一点需要注意,如果需要跨平台使用序列化的数据,那么除了两端使用的算法要一致外,还可能会遇到不同语言对数据类型的兼容问题。这,也是经常踩坑的一个地方。如果你有相关需求,可以多做实验、多测试。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在讨论Redis序列化方式的时候我们自定义了RedisTemplate让Key使用String序列化、让Value使用JSON序列化从而使Redis获得的Value可以直接转换为需要的对象类型。那么使用RedisTemplate&lt;String, Long&gt;能否存取Value是Long的数据呢这其中有什么坑吗
1. 你可以看一下Jackson2ObjectMapperBuilder类源码的实现注意configure方法分析一下其除了关闭FAIL_ON_UNKNOWN_PROPERTIES外还做了什么吗
关于序列化和反序列化,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,583 @@
<audio id="audio" title="16 | 用好Java 8的日期时间类少踩一些“老三样”的坑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/65/9dc24d400103e60d9f3f300d3b9fa565.mp3"></audio>
你好,我是朱晔。今天,我来和你说说恼人的时间错乱问题。
在Java 8之前我们处理日期时间需求时使用Date、Calender和SimpleDateFormat来声明时间戳、使用日历处理日期和格式化解析日期时间。但是这些类的API的缺点比较明显比如可读性差、易用性差、使用起来冗余繁琐还有线程安全问题。
因此Java 8推出了新的日期时间类。每一个类功能明确清晰、类之间协作简单、API定义清晰不踩坑API功能强大无需借助外部工具类即可完成操作并且线程安全。
但是Java 8刚推出的时候诸如序列化、数据访问等类库都还不支持Java 8的日期时间类型需要在新老类中来回转换。比如在业务逻辑层使用LocalDateTime存入数据库或者返回前端的时候还要切换回Date。因此很多同学还是选择使用老的日期时间类。
现在几年时间过去了,几乎所有的类库都支持了新日期时间类型,使用起来也不会有来回切换等问题了。但,很多代码中因为还是用的遗留的日期时间类,因此出现了很多时间错乱的错误实践。比如,试图通过随意修改时区,使读取到的数据匹配当前时钟;再比如,试图直接对读取到的数据做加、减几个小时的操作,来“修正数据”。
今天,我就重点与你分析下时间错乱问题背后的原因,看看使用遗留的日期时间类,来处理日期时间初始化、格式化、解析、计算等可能会遇到的问题,以及如何使用新日期时间类来解决。
## 初始化日期时间
我们先从日期时间的初始化看起。如果要初始化一个2019年12月31日11点12分13秒这样的时间可以使用下面的两行代码吗
```
Date date = new Date(2019, 12, 31, 11, 12, 13);
System.out.println(date);
```
可以看到输出的时间是3029年1月31日11点12分13秒
```
Sat Jan 31 11:12:13 CST 3920
```
相信看到这里你会说这是新手才会犯的低级错误年应该是和1900的差值月应该是从0到11而不是从1到12。
```
Date date = new Date(2019 - 1900, 11, 31, 11, 12, 13);
```
你说的没错但更重要的问题是当有国际化需求时需要使用Calendar类来初始化时间。
使用Calendar改造之后初始化时年参数直接使用当前年即可不过月需要注意是从0到11。当然你也可以直接使用Calendar.DECEMBER来初始化月份更不容易犯错。为了说明时区的问题我分别使用当前时区和纽约时区初始化了两次相同的日期
```
Calendar calendar = Calendar.getInstance();
calendar.set(2019, 11, 31, 11, 12, 13);
System.out.println(calendar.getTime());
Calendar calendar2 = Calendar.getInstance(TimeZone.getTimeZone(&quot;America/New_York&quot;));
calendar2.set(2019, Calendar.DECEMBER, 31, 11, 12, 13);
System.out.println(calendar2.getTime());
```
输出显示了两个时间,说明时区产生了作用。但,我们更习惯年/月/日 时:分:秒这样的日期时间格式,对现在输出的日期格式还不满意:
```
Tue Dec 31 11:12:13 CST 2019
Wed Jan 01 00:12:13 CST 2020
```
那,时区的问题是怎么回事,又怎么格式化需要输出的日期时间呢?接下来,我就与你逐一分析下这两个问题。
## “恼人”的时区问题
我们知道全球有24个时区同一个时刻不同时区比如中国上海和美国纽约的时间是不一样的。对于需要全球化的项目如果初始化时间时没有提供时区那就不是一个真正意义上的时间只能认为是我看到的当前时间的一个表示。
关于Date类我们要有两点认识
- 一是Date并无时区问题世界上任何一台计算机使用new Date()初始化得到的时间都一样。因为Date中保存的是UTC时间UTC是以原子钟为基础的统一时间不以太阳参照计时并无时区划分。
- 二是Date中保存的是一个时间戳代表的是从1970年1月1日0点Epoch时间到现在的毫秒数。尝试输出Date(0)
```
System.out.println(new Date(0));
System.out.println(TimeZone.getDefault().getID() + &quot;:&quot; + TimeZone.getDefault().getRawOffset()/3600000);
```
我得到的是1970年1月1日8点。因为我机器当前的时区是中国上海相比UTC时差+8小时
```
Thu Jan 01 08:00:00 CST 1970
Asia/Shanghai:8
```
对于国际化(世界各国的人都在使用)的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
- 方式一以UTC保存保存的时间没有时区属性是不涉及时区时间差问题的世界统一时间。我们通常说的时间戳或Java中的Date类就是用的这种方式这也是推荐的方式。
- 方式二,以字面量保存,比如年/月/日 时:分:秒一定要同时保存时区信息。只有有了时区信息我们才能知道这个字面量时间真正的时间点否则它只是一个给人看的时间表示只在当前时区有意义。Calendar是有时区概念的所以我们通过不同的时区初始化Calendar得到了不同的时间。
正确保存日期时间之后,就是正确展示,即我们要使用正确的时区,把时间点展示为符合当前时区的时间表示。到这里,我们就能理解为什么会有所谓的“时间错乱”问题了。接下来,我再通过实际案例分析一下,从字面量解析成时间和从时间格式化为字面量这两类问题。
**第一类是**对于同一个时间表示比如2020-01-02 22:00:00不同时区的人转换成Date会得到不同的时间时间戳
```
String stringDate = &quot;2020-01-02 22:00:00&quot;;
SimpleDateFormat inputFormat = new SimpleDateFormat(&quot;yyyy-MM-dd HH:mm:ss&quot;);
//默认时区解析时间表示
Date date1 = inputFormat.parse(stringDate);
System.out.println(date1 + &quot;:&quot; + date1.getTime());
//纽约时区解析时间表示
inputFormat.setTimeZone(TimeZone.getTimeZone(&quot;America/New_York&quot;));
Date date2 = inputFormat.parse(stringDate);
System.out.println(date2 + &quot;:&quot; + date2.getTime());
```
可以看到把2020-01-02 22:00:00这样的时间表示对于当前的上海时区和纽约时区转化为UTC时间戳是不同的时间
```
Thu Jan 02 22:00:00 CST 2020:1577973600000
Fri Jan 03 11:00:00 CST 2020:1578020400000
```
这正是UTC的意义并不是时间错乱。对于同一个本地时间的表示不同时区的人解析得到的UTC时间一定是不同的反过来不同的本地时间可能对应同一个UTC。
**第二类问题是**格式化后出现的错乱即同一个Date在不同的时区下格式化得到不同的时间表示。比如在我的当前时区和纽约时区格式化2020-01-02 22:00:00
```
String stringDate = &quot;2020-01-02 22:00:00&quot;;
SimpleDateFormat inputFormat = new SimpleDateFormat(&quot;yyyy-MM-dd HH:mm:ss&quot;);
//同一Date
Date date = inputFormat.parse(stringDate);
//默认时区格式化输出:
System.out.println(new SimpleDateFormat(&quot;[yyyy-MM-dd HH:mm:ss Z]&quot;).format(date));
//纽约时区格式化输出
TimeZone.setDefault(TimeZone.getTimeZone(&quot;America/New_York&quot;));
System.out.println(new SimpleDateFormat(&quot;[yyyy-MM-dd HH:mm:ss Z]&quot;).format(date));
```
输出如下我当前时区的Offset时差是+8小时对于-5小时的纽约晚上10点对应早上9点
```
[2020-01-02 22:00:00 +0800]
[2020-01-02 09:00:00 -0500]
```
因此有些时候数据库中相同的时间由于服务器的时区设置不同读取到的时间表示不同。这不是时间错乱正是时区发挥了作用因为UTC时间需要根据当前时区解析为正确的本地时间。
所以,**要正确处理时区,在于存进去和读出来两方面**存的时候需要使用正确的当前时区来保存这样UTC时间才会正确读的时候也只有正确设置本地时区才能把UTC时间转换为正确的当地时间。
Java 8推出了新的时间日期类ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime和DateTimeFormatter处理时区问题更简单清晰。我们再用这些类配合一个完整的例子来理解一下时间的解析和展示
- 首先初始化上海、纽约和东京三个时区。我们可以使用ZoneId.of来初始化一个标准的时区也可以使用ZoneOffset.ofHours通过一个offset来初始化一个具有指定时间差的自定义时区。
- 对于日期时间表示LocalDateTime不带有时区属性所以命名为本地时区的日期时间而ZonedDateTime=LocalDateTime+ZoneId具有时区属性。因此LocalDateTime只能认为是一个时间表示ZonedDateTime才是一个有效的时间。在这里我们把2020-01-02 22:00:00这个时间表示使用东京时区来解析得到一个ZonedDateTime。
- 使用DateTimeFormatter格式化时间的时候可以直接通过withZone方法直接设置格式化使用的时区。最后分别以上海、纽约和东京三个时区来格式化这个时间输出
```
//一个时间表示
String stringDate = &quot;2020-01-02 22:00:00&quot;;
//初始化三个时区
ZoneId timeZoneSH = ZoneId.of(&quot;Asia/Shanghai&quot;);
ZoneId timeZoneNY = ZoneId.of(&quot;America/New_York&quot;);
ZoneId timeZoneJST = ZoneOffset.ofHours(9);
//格式化器
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;);
ZonedDateTime date = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneJST);
//使用DateTimeFormatter格式化时间可以通过withZone方法直接设置格式化使用的时区
DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss Z&quot;);
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));
```
可以看到,相同的时区,经过解析存进去和读出来的时间表示是一样的(比如最后一行);而对于不同的时区,比如上海和纽约,最后输出的本地时间不同。+9小时时区的晚上10点对于上海是+8小时所以上海本地时间是晚上9点而对于纽约是-5小时差14小时所以是早上8点
```
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进行时间格式化得到本地时间表示。这样的划分十分清晰、细化也不容易出错。
接下来我们继续看看对于日期时间的格式化和解析使用遗留的SimpleDateFormat会遇到哪些问题。
## 日期时间格式化和解析
每到年底就有很多开发同学踩时间格式化的坑比如“这明明是一个2019年的日期**怎么使用SimpleDateFormat格式化后就提前跨年了**”。我们来重现一下这个问题。
初始化一个Calendar设置日期时间为2019年12月29日使用大写的YYYY来初始化SimpleDateFormat
```
Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
System.out.println(&quot;defaultLocale:&quot; + Locale.getDefault());
Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.DECEMBER, 29,0,0,0);
SimpleDateFormat YYYY = new SimpleDateFormat(&quot;YYYY-MM-dd&quot;);
System.out.println(&quot;格式化: &quot; + YYYY.format(calendar.getTime()));
System.out.println(&quot;weekYear:&quot; + calendar.getWeekYear());
System.out.println(&quot;firstDayOfWeek:&quot; + calendar.getFirstDayOfWeek());
System.out.println(&quot;minimalDaysInFirstWeek:&quot; + calendar.getMinimalDaysInFirstWeek());
```
得到的输出却是2020年12月29日
```
defaultLocale:zh_CN
格式化: 2020-12-29
weekYear:2020
firstDayOfWeek:1
minimalDaysInFirstWeek:1
```
出现这个问题的原因在于这位同学混淆了SimpleDateFormat的各种格式化模式。JDK的[文档](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)中有说明小写y是年而大写Y是week year也就是所在的周属于哪一年。
一年第一周的判断方式是从getFirstDayOfWeek()开始完整的7天并且包含那一年至少getMinimalDaysInFirstWeek()天。这个计算方式和区域相关对于当前zh_CN区域来说2020年第一周的条件是从周日开始的完整7天2020年包含1天即可。显然2019年12月29日周日到2020年1月4日周六是2020年第一周得出的week year就是2020年。
如果把区域改为法国:
```
Locale.setDefault(Locale.FRANCE);
```
那么week yeay就还是2019年因为一周的第一天从周一开始算2020年的第一周是2019年12月30日周一开始29日还是属于去年
```
defaultLocale:fr_FR
格式化: 2019-12-29
weekYear:2019
firstDayOfWeek:2
minimalDaysInFirstWeek:4
```
这个案例告诉我们,没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非 “Y”。
除了格式化表达式容易踩坑外SimpleDateFormat还有两个著名的坑。
第一个坑是,**定义的static的SimpleDateFormat可能会出现线程安全问题。**比如像这样使用一个100线程的线程池循环20次把时间格式化任务提交到线程池处理每个任务中又循环10次解析2020-01-01 11:12:13这样一个时间表示
```
ExecutorService threadPool = Executors.newFixedThreadPool(100);
for (int i = 0; i &lt; 20; i++) {
//提交20个并发解析时间的任务到线程池模拟并发环境
threadPool.execute(() -&gt; {
for (int j = 0; j &lt; 10; j++) {
try {
System.out.println(simpleDateFormat.parse(&quot;2020-01-01 11:12:13&quot;));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
}
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
```
运行程序后大量报错且没有报错的输出结果也不正常比如2020年解析成了1212年
<img src="https://static001.geekbang.org/resource/image/3e/27/3ee2e923b3cf4e13722b7b0773de1b27.png" alt="">
SimpleDateFormat的作用是定义解析和格式化日期时间的模式。这看起来这是一次性的工作应该复用但它的解析和格式化操作是非线程安全的。我们来分析一下相关源码
- SimpleDateFormat继承了DateFormatDateFormat有一个字段Calendar
- SimpleDateFormat的parse方法调用CalendarBuilder的establish方法来构建Calendar
- establish方法内部先清空Calendar再构建Calendar整个操作没有加锁。
显然如果多线程池调用parse方法也就意味着多线程在并发操作一个Calendar可能会产生一个线程还没来得及处理Calendar就被另一个线程清空了的情况
```
public abstract class DateFormat extends Format {
protected Calendar calendar;
}
public class SimpleDateFormat extends DateFormat {
@Override
public Date parse(String text, ParsePosition pos)
{
CalendarBuilder calb = new CalendarBuilder();
parsedDate = calb.establish(calendar).getTime();
return parsedDate;
}
}
class CalendarBuilder {
Calendar establish(Calendar cal) {
...
cal.clear();//清空
for (int stamp = MINIMUM_USER_STAMP; stamp &lt; nextStamp; stamp++) {
for (int index = 0; index &lt;= maxFieldIndex; index++) {
if (field[index] == stamp) {
cal.set(index, field[MAX_FIELD + index]);//构建
break;
}
}
}
return cal;
}
}
```
format方法也类似你可以自己分析。因此只能在同一个线程复用SimpleDateFormat比较好的解决方式是通过ThreadLocal来存放SimpleDateFormat
```
private static ThreadLocal&lt;SimpleDateFormat&gt; threadSafeSimpleDateFormat = ThreadLocal.withInitial(() -&gt; new SimpleDateFormat(&quot;yyyy-MM-dd HH:mm:ss&quot;));
```
第二个坑是,**当需要解析的字符串和格式不匹配的时候SimpleDateFormat表现得很宽容**还是能得到结果。比如我们期望使用yyyyMM来解析20160901字符串
```
String dateString = &quot;20160901&quot;;
SimpleDateFormat dateFormat = new SimpleDateFormat(&quot;yyyyMM&quot;);
System.out.println(&quot;result:&quot; + dateFormat.parse(dateString));
```
居然输出了2091年1月1日原因是把0901当成了月份相当于75年
```
result:Mon Jan 01 00:00:00 CST 2091
```
对于SimpleDateFormat的这三个坑我们使用Java 8中的DateTimeFormatter就可以避过去。首先使用DateTimeFormatterBuilder来定义格式化字符串不用去记忆使用大写的Y还是小写的Y大写的M还是小写的m
```
private static DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR) //年
.appendLiteral(&quot;/&quot;)
.appendValue(ChronoField.MONTH_OF_YEAR) //月
.appendLiteral(&quot;/&quot;)
.appendValue(ChronoField.DAY_OF_MONTH) //日
.appendLiteral(&quot; &quot;)
.appendValue(ChronoField.HOUR_OF_DAY) //时
.appendLiteral(&quot;:&quot;)
.appendValue(ChronoField.MINUTE_OF_HOUR) //分
.appendLiteral(&quot;:&quot;)
.appendValue(ChronoField.SECOND_OF_MINUTE) //秒
.appendLiteral(&quot;.&quot;)
.appendValue(ChronoField.MILLI_OF_SECOND) //毫秒
.toFormatter();
```
其次DateTimeFormatter是线程安全的可以定义为static使用最后DateTimeFormatter的解析比较严格需要解析的字符串和格式不匹配时会直接报错而不会把0901解析为月份。我们测试一下
```
//使用刚才定义的DateTimeFormatterBuilder构建的DateTimeFormatter来解析这个时间
LocalDateTime localDateTime = LocalDateTime.parse(&quot;2020/1/2 12:34:56.789&quot;, dateTimeFormatter);
//解析成功
System.out.println(localDateTime.format(dateTimeFormatter));
//使用yyyyMM格式解析20160901是否可以成功呢
String dt = &quot;20160901&quot;;
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(&quot;yyyyMM&quot;);
System.out.println(&quot;result:&quot; + dateTimeFormatter.parse(dt));
```
输出日志如下:
```
2020/1/2 12:34:56.789
Exception in thread &quot;main&quot; java.time.format.DateTimeParseException: Text '20160901' could not be parsed at index 0
at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1949)
at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1777)
at org.geekbang.time.commonmistakes.datetime.dateformat.CommonMistakesApplication.better(CommonMistakesApplication.java:80)
at org.geekbang.time.commonmistakes.datetime.dateformat.CommonMistakesApplication.main(CommonMistakesApplication.java:41)
```
到这里我们可以发现使用Java 8中的DateTimeFormatter进行日期时间的格式化和解析显然更让人放心。那么对于日期时间的运算使用Java 8中的日期时间类会不会更简单呢
## 日期时间的计算
关于日期时间的计算我先和你说一个常踩的坑。有些同学喜欢直接使用时间戳进行时间计算比如希望得到当前时间之后30天的时间会这么写代码直接把new Date().getTime方法得到的时间戳加30天对应的毫秒数也就是30天*1000毫秒*3600秒*24小时
```
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30 * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);
```
得到的日期居然比当前日期还要早根本不是晚30天的时间
```
Sat Feb 01 14:17:41 CST 2020
Sun Jan 12 21:14:54 CST 2020
```
出现这个问题,**其实是因为int发生了溢出**。修复方式就是把30改为30L让其成为一个long
```
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
System.out.println(today);
System.out.println(nextMonth);
```
这样就可以得到正确结果了:
```
Sat Feb 01 14:17:41 CST 2020
Mon Mar 02 14:17:41 CST 2020
```
不难发现手动在时间戳上进行计算操作的方式非常容易出错。对于Java 8之前的代码我更建议使用Calendar
```
Calendar c = Calendar.getInstance();
c.setTime(new Date());
c.add(Calendar.DAY_OF_MONTH, 30);
System.out.println(c.getTime());
```
使用Java 8的日期时间类型可以直接进行各种计算更加简洁和方便
```
LocalDateTime localDateTime = LocalDateTime.now();
System.out.println(localDateTime.plusDays(30));
```
并且,**对日期时间做计算操作Java 8日期时间API会比Calendar功能强大很多**。
第一可以使用各种minus和plus方法直接对日期进行加减操作比如如下代码实现了减一天和加一天以及减一个月和加一个月
```
System.out.println(&quot;//测试操作日期&quot;);
System.out.println(LocalDate.now()
.minus(Period.ofDays(1))
.plus(1, ChronoUnit.DAYS)
.minusMonths(1)
.plus(Period.ofMonths(1)));
```
可以得到:
```
//测试操作日期
2020-02-01
```
第二还可以通过with方法进行快捷时间调节比如
- 使用TemporalAdjusters.firstDayOfMonth得到当前月的第一天
- 使用TemporalAdjusters.firstDayOfYear()得到当前年的第一天;
- 使用TemporalAdjusters.previous(DayOfWeek.SATURDAY)得到上一个周六;
- 使用TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)得到本月最后一个周五。
```
System.out.println(&quot;//本月的第一天&quot;);
System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfMonth()));
System.out.println(&quot;//今年的程序员日&quot;);
System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfYear()).plusDays(255));
System.out.println(&quot;//今天之前的一个周六&quot;);
System.out.println(LocalDate.now().with(TemporalAdjusters.previous(DayOfWeek.SATURDAY)));
System.out.println(&quot;//本月最后一个工作日&quot;);
System.out.println(LocalDate.now().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)));
```
输出如下:
```
//本月的第一天
2020-02-01
//今年的程序员日
2020-09-12
//今天之前的一个周六
2020-01-25
//本月最后一个工作日
2020-02-28
```
第三可以直接使用lambda表达式进行自定义的时间调整。比如为当前时间增加100天以内的随机天数
```
System.out.println(LocalDate.now().with(temporal -&gt; temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS)));
```
得到:
```
2020-03-15
```
除了计算外,还可以判断日期是否符合某个条件。比如,自定义函数,判断指定日期是否是家庭成员的生日:
```
public static Boolean isFamilyBirthday(TemporalAccessor date) {
int month = date.get(MONTH_OF_YEAR);
int day = date.get(DAY_OF_MONTH);
if (month == Month.FEBRUARY.getValue() &amp;&amp; day == 17)
return Boolean.TRUE;
if (month == Month.SEPTEMBER.getValue() &amp;&amp; day == 21)
return Boolean.TRUE;
if (month == Month.MAY.getValue() &amp;&amp; day == 22)
return Boolean.TRUE;
return Boolean.FALSE;
}
```
然后使用query方法查询是否匹配条件
```
System.out.println(&quot;//查询是否是今天要举办生日&quot;);
System.out.println(LocalDate.now().query(CommonMistakesApplication::isFamilyBirthday));
```
使用Java 8操作和计算日期时间虽然方便但计算两个日期差时可能会踩坑**Java 8中有一个专门的类Period定义了日期间隔通过Period.between得到了两个LocalDate的差返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天直接调用Period的getDays()方法得到的只是最后的“零几天”,而不是算总的间隔天数**。
比如计算2019年12月12日和2019年10月1日的日期间隔很明显日期差是2个月零11天但获取getDays方法得到的结果只是11天而不是72天
```
System.out.println(&quot;//计算日期差&quot;);
LocalDate today = LocalDate.of(2019, 12, 12);
LocalDate specifyDate = LocalDate.of(2019, 10, 1);
System.out.println(Period.between(specifyDate, today).getDays());
System.out.println(Period.between(specifyDate, today));
System.out.println(ChronoUnit.DAYS.between(specifyDate, today));
```
可以使用ChronoUnit.DAYS.between解决这个问题
```
//计算日期差
11
P2M11D
72
```
从日期时间的时区到格式化再到计算你是不是体会到Java 8日期时间类的强大了呢
## 重点回顾
今天我和你一起看了日期时间的初始化、时区、格式化、解析和计算的问题。我们看到使用Java 8中的日期时间包Java.time的类进行各种操作会比使用遗留的Date、Calender和SimpleDateFormat更简单、清晰功能也更丰富、坑也比较少。
如果有条件的话我还是建议全面改为使用Java 8的日期时间类型。我把Java 8前后的日期时间类型汇总到了一张思维导图上图中箭头代表的是新老类型在概念上等价的类型
<img src="https://static001.geekbang.org/resource/image/22/33/225d00087f500dbdf5e666e58ead1433.png" alt="">
这里有个误区是认为java.util.Date类似于新API中的LocalDateTime。其实不是虽然它们都没有时区概念但java.util.Date类是因为使用UTC表示所以没有时区概念其本质是时间戳而LocalDateTime严格上可以认为是一个日期时间的表示而不是一个时间点。
因此在把Date转换为LocalDateTime的时候需要通过Date的toInstant方法得到一个UTC时间戳进行转换并需要提供当前的时区这样才能把UTC时间转换为本地日期时间的表示。反过来把LocalDateTime的时间表示转换为Date时也需要提供时区用于指定是哪个时区的时间表示也就是先通过atZone方法把LocalDateTime转换为ZonedDateTime然后才能获得UTC时间戳
```
Date in = new Date();
LocalDateTime ldt = LocalDateTime.ofInstant(in.toInstant(), ZoneId.systemDefault());
Date out = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
```
很多同学说使用新API很麻烦还需要考虑时区的概念一点都不简洁。但我通过这篇文章要和你说的是并不是因为API需要设计得这么繁琐而是UTC时间要变为当地时间必须考虑时区。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 我今天多次强调Date是一个时间戳是UTC时间、没有时区概念为什么调用其toString方法会输出类似CST之类的时区字样呢
1. 日期时间数据始终要保存到数据库中MySQL中有两种数据类型datetime和timestamp可以用来保存日期时间。你能说说它们的区别吗它们是否包含时区信息呢
对于日期和时间,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,447 @@
<audio id="audio" title="17 | 别以为“自动挡”就不可能出现OOM" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/b4/99f0029fc267d5385ffc6a6828d7b9b4.mp3"></audio>
你好我是朱晔。今天我要和你分享的主题是别以为“自动挡”就不可能出现OOM。
这里的“自动挡”是我对Java自动垃圾收集器的戏称。的确经过这么多年的发展Java的垃圾收集器已经非常成熟了。有了自动垃圾收集器绝大多数情况下我们写程序时可以专注于业务逻辑无需过多考虑对象的分配和释放一般也不会出现OOM。
内存空间始终是有限的Java的几大内存区域始终都有OOM的可能。相应地Java程序的常见OOM类型可以分为堆内存的OOM、栈OOM、元空间OOM、直接内存OOM等。几乎每一种OOM都可以使用几行代码模拟市面上也有很多资料在堆、元空间、直接内存中分配超大对象或是无限分配对象尝试创建无限个线程或是进行方法无限递归调用来模拟。
但值得注意的是我们的业务代码并不会这么干。所以今天我会从内存分配意识的角度通过一些案例展示业务代码中可能导致OOM的一些坑。这些坑或是因为我们意识不到对象的分配或是因为不合理的资源使用或是没有控制缓存的数据量等。
在[第3讲](https://time.geekbang.org/column/article/210337)介绍线程时我们已经看到了两种OOM的情况一是因为使用无界队列导致的堆OOM二是因为使用没有最大线程数量限制的线程池导致无限创建线程的OOM。接下来我们再一起看看在写业务代码的过程中还有哪些意识上的疏忽可能会导致OOM。
## 太多份相同的对象导致OOM
我要分享的第一个案例是这样的。有一个项目在内存中缓存了全量用户数据,在搜索用户时可以直接从缓存中返回用户信息。现在为了改善用户体验,需要实现输入部分用户名自动在下拉框提示补全用户名的功能(也就是所谓的自动完成功能)。
在[第10讲](https://time.geekbang.org/column/article/216778)介绍集合时我提到对于这种快速检索的需求最好使用Map来实现会比直接从List搜索快得多。
为实现这个功能我们需要一个HashMap来存放这些用户数据Key是用户姓名索引Value是索引下对应的用户列表。举一个例子如果有两个用户aa和ab那么Key就有三个分别是a、aa和ab。用户输入字母a时就能从Value这个List中拿到所有字母a开头的用户即aa和ab。
在代码中在数据库中存入1万个测试用户用户名由a~j这6个字母随机构成然后把每一个用户名的前1个字母、前2个字母以此类推直到完整用户名作为Key存入缓存中缓存的Value是一个UserDTO的List存放的是所有相同的用户名索引以及对应的用户信息
```
//自动完成的索引Key是用户输入的部分用户名Value是对应的用户数据
private ConcurrentHashMap&lt;String, List&lt;UserDTO&gt;&gt; autoCompleteIndex = new ConcurrentHashMap&lt;&gt;();
@Autowired
private UserRepository userRepository;
@PostConstruct
public void wrong() {
//先保存10000个用户名随机的用户到数据库中
userRepository.saveAll(LongStream.rangeClosed(1, 10000).mapToObj(i -&gt; new UserEntity(i, randomName())).collect(Collectors.toList()));
//从数据库加载所有用户
userRepository.findAll().forEach(userEntity -&gt; {
int len = userEntity.getName().length();
//对于每一个用户对其用户名的前N位进行索引N可能是1~6六种长度类型
for (int i = 0; i &lt; len; i++) {
String key = userEntity.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -&gt; new ArrayList&lt;&gt;())
.add(new UserDTO(userEntity.getName()));
}
});
log.info(&quot;autoCompleteIndex size:{} count:{}&quot;, autoCompleteIndex.size(),
autoCompleteIndex.entrySet().stream().map(item -&gt; item.getValue().size()).reduce(0, Integer::sum));
}
```
对于每一个用户对象UserDTO除了有用户名我们还加入了10K左右的数据模拟其用户信息
```
@Data
public class UserDTO {
private String name;
@EqualsAndHashCode.Exclude
private String payload;
public UserDTO(String name) {
this.name = name;
this.payload = IntStream.rangeClosed(1, 10_000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;));
}
}
```
运行程序后,日志输出如下:
```
[11:11:22.982] [main] [INFO ] [.t.c.o.d.UsernameAutoCompleteService:37 ] - autoCompleteIndex size:26838 count:60000
```
可以看到一共有26838个索引也就是所有用户名的1位、2位一直到6位有26838个组合HashMap的Value也就是List<userdto>一共有1万个用户*6=6万个UserDTO对象。</userdto>
使用内存分析工具MAT打开堆dump发现6万个UserDTO占用了约1.2GB的内存:
<img src="https://static001.geekbang.org/resource/image/d1/d2/d17fdb7d5123566312f7d3888ef82bd2.png" alt="">
看到这里发现,**虽然真正的用户只有1万个但因为使用部分用户名作为索引的Key导致缓存的Key有26838个缓存的用户信息多达6万个**。如果我们的用户名不是6位而是10位、20位那么缓存的用户信息可能就是10万、20万个必然会产生堆OOM。
尝试调大用户名的最大长度,重启程序可以看到类似如下的错误:
```
[17:30:29.858] [main] [ERROR] [ringframework.boot.SpringApplication:826 ] - Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'usernameAutoCompleteService': Invocation of init method failed; nested exception is java.lang.OutOfMemoryError: Java heap space
```
我们可能会想当然地认为数据库中有1万个用户内存中也应该只有1万个UserDTO对象但实现的时候每次都会new出来UserDTO加入缓存当然在内存中都是新对象。在实际的项目中用户信息的缓存可能是随着用户输入增量缓存的而不是像这个案例一样在程序初始化的时候全量缓存所以问题暴露得不会这么早。
知道原因后解决起来就比较简单了。把所有UserDTO先加入HashSet中因为UserDTO以name来标识唯一性所以重复用户名会被过滤掉最终加入HashSet的UserDTO就不足1万个。
有了HashSet来缓存所有可能的UserDTO信息我们再构建自动完成索引autoCompleteIndex这个HashMap时就可以直接从HashSet获取所有用户信息来构建了。这样一来同一个用户名前缀的不同组合比如用户名为abc的用户a、ab和abc三个Key关联到UserDTO是同一份
```
@PostConstruct
public void right() {
...
HashSet&lt;UserDTO&gt; cache = userRepository.findAll().stream()
.map(item -&gt; new UserDTO(item.getName()))
.collect(Collectors.toCollection(HashSet::new));
cache.stream().forEach(userDTO -&gt; {
int len = userDTO.getName().length();
for (int i = 0; i &lt; len; i++) {
String key = userDTO.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -&gt; new ArrayList&lt;&gt;())
.add(userDTO);
}
});
...
}
```
再次分析堆内存可以看到UserDTO只有9945份总共占用的内存不到200M。这才是我们真正想要的结果。
<img src="https://static001.geekbang.org/resource/image/34/52/34a0fc90ac8be7a20cb295c14f06d752.png" alt="">
修复后的程序不仅相同的UserDTO只有一份总副本数变为了原来的六分之一而且因为HashSet的去重特性双重节约了内存。
值得注意的是我们虽然清楚数据总量但却忽略了每一份数据在内存中可能有多份。我之前还遇到一个案例一个后台程序需要从数据库加载大量信息用于数据导出这些数据在数据库中占用100M内存但是1GB的JVM堆却无法完成导出操作。
我来和你分析下原因吧。100M的数据加载到程序内存中变为Java的数据结构就已经占用了200M堆内存这些数据经过JDBC、MyBatis等框架其实是加载了2份然后领域模型、DTO再进行转换可能又加载了2次最终占用的内存达到了200M*4=800M。
所以,**在进行容量评估时,我们不能认为一份数据在程序内存中也是一份**。
## 使用WeakHashMap不等于不会OOM
对于上一节实现快速检索的案例为了防止缓存中堆积大量数据导致OOM一些同学可能会想到使用WeakHashMap作为缓存容器。
WeakHashMap的特点是Key在哈希表内部是弱引用的当没有强引用指向这个Key之后Entry会被GC即使我们无限往WeakHashMap加入数据只要Key不再使用也就不会OOM。
说到了强引用和弱引用我先和你回顾下Java中引用类型和垃圾回收的关系
- 垃圾回收器不会回收有强引用的对象;
- 在内存充足时,垃圾回收器不会回收具有软引用的对象;
- 垃圾回收器只要扫描到了具有弱引用的对象就会回收WeakHashMap就是利用了这个特点。
不过我要和你分享的第二个案例恰巧就是不久前我遇到的一个使用WeakHashMap却最终OOM的案例。我们暂且不论使用WeakHashMap作为缓存是否合适先分析一下这个OOM问题。
声明一个Key是User类型、Value是UserProfile类型的WeakHashMap作为用户数据缓存往其中添加200万个Entry然后使用ScheduledThreadPoolExecutor发起一个定时任务每隔1秒输出缓存中的Entry个数
```
private Map&lt;User, UserProfile&gt; cache = new WeakHashMap&lt;&gt;();
@GetMapping(&quot;wrong&quot;)
public void wrong() {
String userName = &quot;zhuye&quot;;
//间隔1秒定时输出缓存中的条目数
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
() -&gt; log.info(&quot;cache size:{}&quot;, cache.size()), 1, 1, TimeUnit.SECONDS);
LongStream.rangeClosed(1, 2000000).forEach(i -&gt; {
User user = new User(userName + i);
cache.put(user, new UserProfile(user, &quot;location&quot; + i));
});
}
```
执行程序后日志如下:
```
[10:30:28.509] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29 ] - cache size:2000000
[10:30:29.507] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29 ] - cache size:2000000
[10:30:30.509] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29 ] - cache size:2000000
```
可以看到输出的cache size始终是200万即使我们通过jvisualvm进行手动GC还是这样。这就说明这些Entry无法通过GC回收。如果你把200万改为1000万就可以在日志中看到如下的OOM错误
```
Exception in thread &quot;http-nio-45678-exec-1&quot; java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread &quot;Catalina-utility-2&quot; java.lang.OutOfMemoryError: GC overhead limit exceeded
```
我们来分析一下这个问题。进行堆转储后可以看到堆内存中有200万个UserProfie和User
<img src="https://static001.geekbang.org/resource/image/b9/e9/b9bb8ef163a07a8da92e6e66a6dd55e9.png" alt="">
如下是User和UserProfile类的定义需要注意的是WeakHashMap的Key是User对象而其Value是UserProfile对象持有了User的引用
```
@Data
@AllArgsConstructor
@NoArgsConstructor
class User {
private String name;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserProfile {
private User user;
private String location;
}
```
没错这就是问题的所在。分析一下WeakHashMap的源码你会发现WeakHashMap和HashMap的最大区别是Entry对象的实现。接下来我们暂且忽略HashMap的实现来看下Entry对象
```
private static class Entry&lt;K,V&gt; extends WeakReference&lt;Object&gt; ...
/**
* Creates new entry.
*/
Entry(Object key, V value,
ReferenceQueue&lt;Object&gt; queue,
int hash, Entry&lt;K,V&gt; next) {
super(key, queue);
this.value = value;
this.hash = hash;
this.next = next;
}
```
Entry对象继承了WeakReferenceEntry的构造函数调用了super (key,queue)这是父类的构造函数。其中key是我们执行put方法时的keyqueue是一个ReferenceQueue。如果你了解Java的引用就会知道被GC的对象会被丢进这个queue里面。
再来看看对象被丢进queue后是如何被销毁的
```
public V get(Object key) {
Object k = maskNull(key);
int h = hash(k);
Entry&lt;K,V&gt;[] tab = getTable();
int index = indexFor(h, tab.length);
Entry&lt;K,V&gt; e = tab[index];
while (e != null) {
if (e.hash == h &amp;&amp; eq(k, e.get()))
return e.value;
e = e.next;
}
return null;
}
private Entry&lt;K,V&gt;[] getTable() {
expungeStaleEntries();
return table;
}
/**
* Expunges stale entries from the table.
*/
private void expungeStaleEntries() {
for (Object x; (x = queue.poll()) != null; ) {
synchronized (queue) {
@SuppressWarnings(&quot;unchecked&quot;)
Entry&lt;K,V&gt; e = (Entry&lt;K,V&gt;) x;
int i = indexFor(e.hash, table.length);
Entry&lt;K,V&gt; prev = table[i];
Entry&lt;K,V&gt; p = prev;
while (p != null) {
Entry&lt;K,V&gt; next = p.next;
if (p == e) {
if (prev == e)
table[i] = next;
else
prev.next = next;
// Must not null out e.next;
// stale entries may be in use by a HashIterator
e.value = null; // Help GC
size--;
break;
}
prev = p;
`` p = next;
}
}
}
}
```
从源码中可以看到每次调用get、put、size等方法时都会从queue里拿出所有已经被GC掉的key并删除对应的Entry对象。我们再来回顾下这个逻辑
- put一个对象进Map时它的key会被封装成弱引用对象
- 发生GC时弱引用的key被发现并放入queue
- 调用get等方法时扫描queue删除key以及包含key和value的Entry对象。
**WeakHashMap的Key虽然是弱引用但是其Value却持有Key中对象的强引用Value被Entry引用Entry被WeakHashMap引用最终导致Key无法回收**。解决方案就是让Value变为弱引用使用WeakReference来包装UserProfile即可
```
private Map&lt;User, WeakReference&lt;UserProfile&gt;&gt; cache2 = new WeakHashMap&lt;&gt;();
@GetMapping(&quot;right&quot;)
public void right() {
String userName = &quot;zhuye&quot;;
//间隔1秒定时输出缓存中的条目数
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
() -&gt; log.info(&quot;cache size:{}&quot;, cache2.size()), 1, 1, TimeUnit.SECONDS);
LongStream.rangeClosed(1, 2000000).forEach(i -&gt; {
User user = new User(userName + i);
//这次我们使用弱引用来包装UserProfile
cache2.put(user, new WeakReference(new UserProfile(user, &quot;location&quot; + i)));
});
}
```
重新运行程序从日志中观察到cache size不再是固定的200万而是在不断减少甚至在手动GC后所有的Entry都被回收了
```
[10:40:05.792] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:1367402
[10:40:05.795] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:1367846
[10:40:06.773] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:549551
...
[10:40:20.742] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:549551
[10:40:22.862] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:547937
[10:40:22.865] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:542134
[10:40:23.779] [pool-3-thread-1] [INFO ]
//手动进行GC
[t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:0
```
当然还有一种办法就是让Value也就是UserProfile不再引用Key而是重新new出一个新的User对象赋值给UserProfile
```
@GetMapping(&quot;right2&quot;)
public void right2() {
String userName = &quot;zhuye&quot;;
...
User user = new User(userName + i);
cache.put(user, new UserProfile(new User(user.getName()), &quot;location&quot; + i));
});
}
```
此外Spring提供的[ConcurrentReferenceHashMap](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/ConcurrentReferenceHashMap.html)类可以使用弱引用、软引用做缓存Key和Value同时被软引用或弱引用包装也能解决相互引用导致的数据不能释放问题。与WeakHashMap相比ConcurrentReferenceHashMap不但性能更好还可以确保线程安全。你可以自己做实验测试下。
## Tomcat参数配置不合理导致OOM
我们再来看看第三个案例。有一次运维同学反馈有个应用在业务量大的情况下会出现假死日志中也有大量OOM异常
```
[13:18:17.597] [http-nio-45678-exec-70] [ERROR] [ache.coyote.http11.Http11NioProtocol:175 ] - Failed to complete processing of a request
java.lang.OutOfMemoryError: Java heap space
```
于是我让运维同学进行生产堆Dump。通过MAT打开dump文件后我们一眼就看到OOM的原因是有接近1.7GB的byte数组分配而JVM进程的最大堆内存我们只配置了2GB
<img src="https://static001.geekbang.org/resource/image/0b/ee/0b310e7da83f272afdf51b345d8057ee.png" alt="">
通过查看引用可以发现大量引用都是Tomcat的工作线程。大部分工作线程都分配了两个10M左右的数组100个左右工作线程吃满了内存。第一个红框是Http11InputBuffer其buffer大小是10008192字节而第二个红框的Http11OutputBuffer的buffer正好占用10000000字节
<img src="https://static001.geekbang.org/resource/image/53/12/53546299958a4fecef5fd473a0579012.png" alt="">
我们先来看看第一个Http11InputBuffer为什么会占用这么多内存。查看Http11InputBuffer类的init方法注意到其中一个初始化方法会分配headerBufferSize+readBuffer大小的内存
```
void init(SocketWrapperBase&lt;?&gt; socketWrapper) {
wrapper = socketWrapper;
wrapper.setAppReadBufHandler(this);
int bufLength = headerBufferSize +
wrapper.getSocketBufferHandler().getReadBuffer().capacity();
if (byteBuffer == null || byteBuffer.capacity() &lt; bufLength) {
byteBuffer = ByteBuffer.allocate(bufLength);
byteBuffer.position(0).limit(0);
}
}
```
在[Tomcat文档](https://tomcat.apache.org/tomcat-8.0-doc/config/http.html)中有提到这个Socket的读缓冲也就是readBuffer默认是8192字节。显然问题出在了headerBufferSize上
<img src="https://static001.geekbang.org/resource/image/0c/68/0c14d6aff749d74b3ee0e159e4552168.png" alt="">
向上追溯初始化Http11InputBuffer的Http11Processor类可以看到传入的headerBufferSize配置的是MaxHttpHeaderSize
```
inputBuffer = new Http11InputBuffer(request, protocol.getMaxHttpHeaderSize(),
protocol.getRejectIllegalHeaderName(), httpParser);
```
Http11OutputBuffer中的buffer正好占用了10000000字节这又是为什么通过Http11OutputBuffer的构造方法可以看到它是直接根据headerBufferSize分配了固定大小的headerBuffer
```
protected Http11OutputBuffer(Response response, int headerBufferSize){
...
headerBuffer = ByteBuffer.allocate(headerBufferSize);
}
```
那么我们就可以想到一定是应用把Tomcat头相关的参数配置为10000000了使得每一个请求对于Request和Response都占用了20M内存最终在并发较多的情况下引起了OOM。
果不其然,查看项目代码发现配置文件中有这样的配置项:
```
server.max-http-header-size=10000000
```
翻看源码提交记录可以看到,当时开发同学遇到了这样的异常:
```
java.lang.IllegalArgumentException: Request header is too large
```
于是他就到网上搜索了一下解决方案随意将server.max-http-header-size修改为了一个超大值期望永远不会再出现类似问题。但没想到这个修改却引起了这么大的问题。把这个参数改为比较合适的20000再进行压测我们就可以发现应用的各项指标都比较稳定。
这个案例告诉我们,**一定要根据实际需求来修改参数配置可以考虑预留2到5倍的量。容量类的参数背后往往代表了资源设置超大的参数就有可能占用不必要的资源在并发量大的时候因为资源大量分配导致OOM**。
## 重点回顾
今天我从内存分配意识的角度和你分享了OOM的问题。通常而言Java程序的OOM有如下几种可能。
一是我们的程序确实需要超出JVM配置的内存上限的内存。不管是程序实现的不合理还是因为各种框架对数据的重复处理、加工和转换相同的数据在内存中不一定只占用一份空间。针对内存量使用超大的业务逻辑比如缓存逻辑、文件上传下载和导出逻辑我们在做容量评估时可能还需要实际做一下Dump而不是进行简单的假设。
二是出现内存泄露其实就是我们认为没有用的对象最终会被GC但却没有。GC并不会回收强引用对象我们可能经常在程序中定义一些容器作为缓存但如果容器中的数据无限增长要特别小心最终会导致OOM。使用WeakHashMap是解决这个问题的好办法但值得注意的是如果强引用的Value有引用Key也无法回收Entry。
三是不合理的资源需求配置在业务量小的时候可能不会出现问题但业务量一大可能很快就会撑爆内存。比如随意配置Tomcat的max-http-header-size参数会导致一个请求使用过多的内存请求量大的时候出现OOM。在进行参数配置的时候我们要认识到很多限制类参数限制的是背后资源的使用资源始终是有限的需要根据实际需求来合理设置参数。
最后我想说的是在出现OOM之后也不用过于紧张。我们可以根据错误日志中的异常信息再结合jstat等命令行工具观察内存使用情况以及程序的GC日志来大致定位出现OOM的内存区块和类型。其实我们遇到的90%的OOM都是堆OOM对JVM进程进行堆内存Dump或使用jmap命令分析对象内存占用排行一般都可以很容易定位到问题。
这里,**我建议你为生产系统的程序配置JVM参数启用详细的GC日志方便观察垃圾收集器的行为并开启HeapDumpOnOutOfMemoryError以便在出现OOM时能自动Dump留下第一问题现场**。对于JDK8你可以这么设置
```
XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
```
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. Spring的ConcurrentReferenceHashMap针对Key和Value支持软引用和弱引用两种方式。你觉得哪种方式更适合做缓存呢
1. 当我们需要动态执行一些表达式时可以使用Groovy动态语言实现new出一个GroovyShell类然后调用evaluate方法动态执行脚本。这种方式的问题是会重复产生大量的类增加Metaspace区的GC负担有可能会引起OOM。你知道如何避免这个问题吗
针对OOM或内存泄露你还遇到过什么案例吗我是朱晔欢迎在评论区与我留言分享也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,453 @@
<audio id="audio" title="18 | 当反射、注解和泛型遇到OOP时会有哪些坑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/4d/fa9bd7ef5d3156e34aa86eaec1fe0f4d.mp3"></audio>
你好我是朱晔。今天我们聊聊Java高级特性的话题看看反射、注解和泛型遇到重载和继承时可能会产生的坑。
你可能说业务项目中几乎都是增删改查用到反射、注解和泛型这些高级特性的机会少之又少没啥好学的。但我要说的是只有学好、用好这些高级特性才能开发出更简洁易读的代码而且几乎所有的框架都使用了这三大高级特性。比如要减少重复代码就得用到反射和注解详见第21讲
如果你从来没用过反射、注解和泛型,可以先通过官网有一个大概了解:
- [Java Reflection API](https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/index.html) &amp; [Reflection Tutorials](https://docs.oracle.com/javase/tutorial/reflect/index.html)
- [Annotations](https://docs.oracle.com/javase/8/docs/technotes/guides/language/annotations.html) &amp; [Lesson: Annotations](https://docs.oracle.com/javase/tutorial/java/annotations/index.html)
- [Generics](https://docs.oracle.com/javase/8/docs/technotes/guides/language/generics.html) &amp; [Lesson: Generics](https://docs.oracle.com/javase/tutorial/java/generics/index.html)。
接下来我们就通过几个案例看看这三大特性结合OOP使用时会有哪些坑吧。
## 反射调用方法不是以传参决定重载
反射的功能包括在运行时动态获取类和类成员定义以及动态读取属性调用方法。也就是说针对类动态调用方法不管类中字段和方法怎么变动我们都可以用相同的规则来读取信息和执行方法。因此几乎所有的ORM对象关系映射、对象映射、MVC框架都使用了反射。
反射的起点是Class类Class类提供了各种方法帮我们查询它的信息。你可以通过这个[文档](https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html),了解每一个方法的作用。
接下来我们先看一个反射调用方法遇到重载的坑有两个叫age的方法入参分别是基本类型int和包装类型Integer。
```
@Slf4j
public class ReflectionIssueApplication {
private void age(int age) {
log.info(&quot;int age = {}&quot;, age);
}
private void age(Integer age) {
log.info(&quot;Integer age = {}&quot;, age);
}
}
```
如果不通过反射调用走哪个重载方法很清晰比如传入36走int参数的重载方法传入Integer.valueOf(“36”)走Integer重载
```
ReflectionIssueApplication application = new ReflectionIssueApplication();
application.age(36);
application.age(Integer.valueOf(&quot;36&quot;));
```
**但使用反射时的误区是,认为反射调用方法还是根据入参确定方法重载**。比如使用getDeclaredMethod来获取age方法然后传入Integer.valueOf(“36”)
```
getClass().getDeclaredMethod(&quot;age&quot;, Integer.TYPE).invoke(this, Integer.valueOf(&quot;36&quot;));
```
输出的日志证明走的是int重载方法
```
14:23:09.801 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - int age = 36
```
其实要通过反射进行方法调用第一步就是通过方法签名来确定方法。具体到这个案例getDeclaredMethod传入的参数类型Integer.TYPE代表的是int所以实际执行方法时无论传的是包装类型还是基本类型都会调用int入参的age方法。
把Integer.TYPE改为Integer.class执行的参数类型就是包装类型的Integer。这时无论传入的是Integer.valueOf(“36”)还是基本类型的36
```
getClass().getDeclaredMethod(&quot;age&quot;, Integer.class).invoke(this, Integer.valueOf(&quot;36&quot;));
getClass().getDeclaredMethod(&quot;age&quot;, Integer.class).invoke(this, 36);
```
都会调用Integer为入参的age方法
```
14:25:18.028 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - Integer age = 36
14:25:18.029 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - Integer age = 36
```
现在我们非常清楚了,反射调用方法,是以反射获取方法时传入的方法名称和参数类型来确定调用方法的。接下来,我们再来看一下反射、泛型擦除和继承结合在一起会碰撞出什么坑。
## 泛型经过类型擦除多出桥接方法的坑
泛型是一种风格或范式,一般用于强类型程序设计语言,允许开发者使用类型参数替代明确的类型,实例化时再指明具体的类型。它是代码重用的有效手段,允许把一套代码应用到多种数据类型上,避免针对每一种数据类型实现重复的代码。
Java 编译器对泛型应用了强大的类型检测,如果代码违反了类型安全就会报错,可以在编译时暴露大多数泛型的编码错误。但总有一部分编码错误,比如泛型类型擦除的坑,在运行时才会暴露。接下来,我就和你分享一个案例吧。
有一个项目希望在类字段内容变动时记录日志,于是开发同学就想到定义一个泛型父类,并在父类中定义一个统一的日志记录方法,子类可以通过继承重用这个方法。代码上线后业务没啥问题,但总是出现日志重复记录的问题。开始时,我们怀疑是日志框架的问题,排查到最后才发现是泛型的问题,反复修改多次才解决了这个问题。
父类是这样的有一个泛型占位符T有一个AtomicInteger计数器用来记录value字段更新的次数其中value字段是泛型T类型的setValue方法每次为value赋值时对计数器进行+1操作。我重写了toString方法输出value字段的值和计数器的值
```
class Parent&lt;T&gt; {
//用于记录value更新的次数模拟日志记录的逻辑
AtomicInteger updateCount = new AtomicInteger();
private T value;
//重写toString输出值和值更新次数
@Override
public String toString() {
return String.format(&quot;value: %s updateCount: %d&quot;, value, updateCount.get());
}
//设置值
public void setValue(T value) {
this.value = value;
updateCount.incrementAndGet();
}
}
```
子类Child1的实现是这样的继承父类但没有提供父类泛型参数定义了一个参数为String的setValue方法通过super.setValue调用父类方法实现日志记录。我们也能明白开发同学这么设计是希望覆盖父类的setValue实现
```
class Child1 extends Parent {
public void setValue(String value) {
System.out.println(&quot;Child1.setValue called&quot;);
super.setValue(value);
}
}
```
在实现的时候子类方法的调用是通过反射进行的。实例化Child1类型后通过getClass().getMethods方法获得所有的方法然后按照方法名过滤出setValue方法进行调用传入字符串test作为参数
```
Child1 child1 = new Child1();
Arrays.stream(child1.getClass().getMethods())
.filter(method -&gt; method.getName().equals(&quot;setValue&quot;))
.forEach(method -&gt; {
try {
method.invoke(child1, &quot;test&quot;);
} catch (Exception e) {
e.printStackTrace();
}
});
System.out.println(child1.toString());
```
运行代码后可以看到虽然Parent的value字段正确设置了test但父类的setValue方法调用了两次计数器也显示2而不是1
```
Child1.setValue called
Parent.setValue called
Parent.setValue called
value: test updateCount: 2
```
显然两次Parent的setValue方法调用是因为getMethods方法找到了两个名为setValue的方法分别是父类和子类的setValue方法。
这个案例中,子类方法重写父类方法失败的原因,包括两方面:
- 一是子类没有指定String泛型参数父类的泛型方法setValue(T value)在泛型擦除后是setValue(Object value)子类中入参是String的setValue方法被当作了新方法
- 二是,**子类的setValue方法没有增加@Override注解,因此编译器没能检测到重写失败的问题。这就说明,重写子类方法时,标记@Override是一个好习惯**。
但是开发同学认为问题出在反射API使用不当却没意识到重写失败。他查文档后发现getMethods方法能获得当前类和父类的所有public方法而getDeclaredMethods只能获得当前类所有的public、protected、package和private方法。
于是他就用getDeclaredMethods替代了getMethods
```
Arrays.stream(child1.getClass().getDeclaredMethods())
.filter(method -&gt; method.getName().equals(&quot;setValue&quot;))
.forEach(method -&gt; {
try {
method.invoke(child1, &quot;test&quot;);
} catch (Exception e) {
e.printStackTrace();
}
});
```
这样虽然能解决重复记录日志的问题,但没有解决子类方法重写父类方法失败的问题,得到如下输出:
```
Child1.setValue called
Parent.setValue called
value: test updateCount: 1
```
其实这治标不治本其他人使用Child1时还是会发现有两个setValue方法非常容易让人困惑。
幸好架构师在修复上线前发现了这个问题让开发同学重新实现了Child2继承Parent的时候提供了String作为泛型T类型并使用@Override关键字注释了setValue方法,实现了真正有效的方法重写:
```
class Child2 extends Parent&lt;String&gt; {
@Override
public void setValue(String value) {
System.out.println(&quot;Child2.setValue called&quot;);
super.setValue(value);
}
}
```
但很可惜,修复代码上线后,还是出现了日志重复记录:
```
Child2.setValue called
Parent.setValue called
Child2.setValue called
Parent.setValue called
value: test updateCount: 2
```
可以看到这次是Child2类的setValue方法被调用了两次。开发同学惊讶地说肯定是反射出Bug了通过getDeclaredMethods查找到的方法一定是来自Child2类本身而且怎么看Child2类中也只有一个setValue方法为什么还会重复呢
调试一下可以发现Child2类其实有2个setValue方法入参分别是String和Object。
<img src="https://static001.geekbang.org/resource/image/81/b8/81116d6f11440f92757e4fe775df71b8.png" alt="">
如果不通过反射来调用方法,我们确实很难发现这个问题。**其实,这就是泛型类型擦除导致的问题**。我们来分析一下。
我们知道Java的泛型类型在编译后擦除为Object。虽然子类指定了父类泛型T类型是String但编译后T会被擦除成为Object所以父类setValue方法的入参是Objectvalue也是Object。如果子类Child2的setValue方法要覆盖父类的setValue方法那入参也必须是Object。所以编译器会为我们生成一个所谓的bridge桥接方法你可以使用javap命令来反编译编译后的Child2类的class字节码
```
javap -c /Users/zhuye/Documents/common-mistakes/target/classes/org/geekbang/time/commonmistakes/advancedfeatures/demo3/Child2.class
Compiled from &quot;GenericAndInheritanceApplication.java&quot;
class org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2 extends org.geekbang.time.commonmistakes.advancedfeatures.demo3.Parent&lt;java.lang.String&gt; {
org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2();
Code:
0: aload_0
1: invokespecial #1 // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent.&quot;&lt;init&gt;&quot;:()V
4: return
public void setValue(java.lang.String);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Child2.setValue called
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: aload_1
10: invokespecial #5 // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent.setValue:(Ljava/lang/Object;)V
13: return
public void setValue(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: checkcast #6 // class java/lang/String
5: invokevirtual #7 // Method setValue:(Ljava/lang/String;)V
8: return
}
```
可以看到入参为Object的setValue方法在内部调用了入参为String的setValue方法第27行也就是代码里实现的那个方法。如果编译器没有帮我们实现这个桥接方法那么Child2子类重写的是父类经过泛型类型擦除后、入参是Object的setValue方法。这两个方法的参数一个是String一个是Object明显不符合Java的语义
```
class Parent {
AtomicInteger updateCount = new AtomicInteger();
private Object value;
public void setValue(Object value) {
System.out.println(&quot;Parent.setValue called&quot;);
this.value = value;
updateCount.incrementAndGet();
}
}
class Child2 extends Parent {
@Override
public void setValue(String value) {
System.out.println(&quot;Child2.setValue called&quot;);
super.setValue(value);
}
}
```
使用jclasslib工具打开Child2类同样可以看到入参为Object的桥接方法上标记了public + synthetic + bridge三个属性。synthetic代表由编译器生成的不可见代码bridge代表这是泛型类型擦除后生成的桥接代码
<img src="https://static001.geekbang.org/resource/image/b5/08/b5e30fb0ade19d71cd7fad1730e85808.png" alt="">
知道这个问题之后修改方式就明朗了可以使用method的isBridge方法来判断方法是不是桥接方法
- 通过getDeclaredMethods方法获取到所有方法后必须同时根据方法名setValue和非isBridge两个条件过滤才能实现唯一过滤
- 使用Stream时如果希望只匹配0或1项的话可以考虑配合ifPresent来使用findFirst方法。
修复代码如下:
```
Arrays.stream(child2.getClass().getDeclaredMethods())
.filter(method -&gt; method.getName().equals(&quot;setValue&quot;) &amp;&amp; !method.isBridge())
.findFirst().ifPresent(method -&gt; {
try {
method.invoke(chi2, &quot;test&quot;);
} catch (Exception e) {
e.printStackTrace();
}
});
```
这样就可以得到正确输出了:
```
Child2.setValue called
Parent.setValue called
value: test updateCount: 1
```
**最后小结下,使用反射查询类方法清单时,我们要注意两点**
- getMethods和getDeclaredMethods是有区别的前者可以查询到父类方法后者只能查询到当前类。
- 反射进行方法调用要注意过滤桥接方法。
## 注解可以继承吗?
注解可以为Java代码提供元数据各种框架也都会利用注解来暴露功能比如Spring框架中的@Service@Controller@Bean注解Spring Boot的@SpringBootApplication注解
框架可以通过类或方法等元素上标记的注解来了解它们的功能或特性并以此来启用或执行相应的功能。通过注解而不是API调用来配置框架属于声明式交互可以简化框架的配置工作也可以和框架解耦。
开发同学可能会认为,类继承后,类的注解也可以继承,子类重写父类方法后,父类方法上的注解也能作用于子类,但这些观点其实是错误或者说是不全面的。我们来验证下吧。
首先定义一个包含value属性的MyAnnotation注解可以标记在方法或类上
```
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
String value();
}
```
然后,定义一个标记了@MyAnnotation注解的父类Parent设置value为Class字符串同时这个类的foo方法也标记了@MyAnnotation注解设置value为Method字符串。接下来定义一个子类Child继承Parent父类并重写父类的foo方法子类的foo方法和类上都没有@MyAnnotation注解
```
@MyAnnotation(value = &quot;Class&quot;)
@Slf4j
static class Parent {
@MyAnnotation(value = &quot;Method&quot;)
public void foo() {
}
}
@Slf4j
static class Child extends Parent {
@Override
public void foo() {
}
}
```
再接下来通过反射分别获取Parent和Child的类和方法的注解信息并输出注解的value属性的值如果注解不存在则输出空字符串
```
private static String getAnnotationValue(MyAnnotation annotation) {
if (annotation == null) return &quot;&quot;;
return annotation.value();
}
public static void wrong() throws NoSuchMethodException {
//获取父类的类和方法上的注解
Parent parent = new Parent();
log.info(&quot;ParentClass:{}&quot;, getAnnotationValue(parent.getClass().getAnnotation(MyAnnotation.class)));
log.info(&quot;ParentMethod:{}&quot;, getAnnotationValue(parent.getClass().getMethod(&quot;foo&quot;).getAnnotation(MyAnnotation.class)));
//获取子类的类和方法上的注解
Child child = new Child();
log.info(&quot;ChildClass:{}&quot;, getAnnotationValue(child.getClass().getAnnotation(MyAnnotation.class)));
log.info(&quot;ChildMethod:{}&quot;, getAnnotationValue(child.getClass().getMethod(&quot;foo&quot;).getAnnotation(MyAnnotation.class)));
}
```
输出如下:
```
17:34:25.495 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentClass:Class
17:34:25.501 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentMethod:Method
17:34:25.504 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:
17:34:25.504 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:
```
可以看到,父类的类和方法上的注解都可以正确获得,但是子类的类和方法却不能。这说明,**子类以及子类的方法,无法自动继承父类和父类方法上的注解**。
如果你详细了解过注解应该知道,在注解上标记@Inherited元注解可以实现注解的继承。那么,把@MyAnnotation注解标记了@Inherited,就可以一键解决问题了吗?
```
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface MyAnnotation {
String value();
}
```
重新运行代码输出如下:
```
17:44:54.831 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentClass:Class
17:44:54.837 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentMethod:Method
17:44:54.838 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:Class
17:44:54.838 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:
```
可以看到子类可以获得父类上的注解子类foo方法虽然是重写父类方法并且注解本身也支持继承但还是无法获得方法上的注解。
如果你再仔细阅读一下[@Inherited的文档](https://docs.oracle.com/javase/8/docs/api/java/lang/annotation/Inherited.html)就会发现,@Inherited只能实现类上的注解继承。要想实现方法上注解的继承,你可以通过反射在继承链上找到方法上的注解。但,这样实现起来很繁琐,而且需要考虑桥接方法。
好在Spring提供了AnnotatedElementUtils类来方便我们处理注解的继承问题。这个类的findMergedAnnotation工具方法可以帮助我们找出父类和接口、父类方法和接口方法上的注解并可以处理桥接方法实现一键找到继承链的注解
```
Child child = new Child();
log.info(&quot;ChildClass:{}&quot;, getAnnotationValue(AnnotatedElementUtils.findMergedAnnotation(child.getClass(), MyAnnotation.class)));
log.info(&quot;ChildMethod:{}&quot;, getAnnotationValue(AnnotatedElementUtils.findMergedAnnotation(child.getClass().getMethod(&quot;foo&quot;), MyAnnotation.class)));
```
修改后,可以得到如下输出:
```
17:47:30.058 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:Class
17:47:30.059 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:Method
```
可以看到子类foo方法也获得了父类方法上的注解。
## 重点回顾
今天我和你分享了使用Java反射、注解和泛型高级特性配合OOP时可能会遇到的一些坑。
第一,反射调用方法并不是通过调用时的传参确定方法重载,而是在获取方法的时候通过方法名和参数类型来确定的。遇到方法有包装类型和基本类型重载的时候,你需要特别注意这一点。
第二反射获取类成员需要注意getXXX和getDeclaredXXX方法的区别其中XXX包括Methods、Fields、Constructors、Annotations。这两类方法针对不同的成员类型XXX和对象在实现上都有一些细节差异详情请查看[官方文档](https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html)。今天提到的getDeclaredMethods方法无法获得父类定义的方法而getMethods方法可以只是差异之一不能适用于所有的XXX。
第三泛型因为类型擦除会导致泛型方法T占位符被替换为Object子类如果使用具体类型覆盖父类实现编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义又满足子类实现的方法有具体的类型。使用反射来获取方法清单时你需要特别注意这一点。
第四,自定义注解可以通过标记元注解@Inherited实现注解的继承不过这只适用于类。如果要继承定义在接口或方法上的注解可以使用Spring的工具类AnnotatedElementUtils并注意各种getXXX方法和findXXX方法的区别详情查看[Spring的文档](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/core/annotation/AnnotatedElementUtils.html)。
最后我要说的是。编译后的代码和原始代码并不完全一致编译器可能会做一些优化加上还有诸如AspectJ等编译时增强框架使用反射动态获取类型的元数据可能会和我们编写的源码有差异这点需要特别注意。你可以在反射中多写断言遇到非预期的情况直接抛异常避免通过反射实现的业务逻辑不符合预期。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 泛型类型擦除后会生成一个bridge方法这个方法同时又是synthetic方法。除了泛型类型擦除你知道还有什么情况编译器会生成synthetic方法吗
1. 关于注解继承问题你觉得Spring的常用注解@Service@Controller是否支持继承呢
你还遇到过与Java高级特性相关的其他坑吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,541 @@
<audio id="audio" title="19 | Spring框架IoC和AOP是扩展的核心" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/eb/b1af6cf12f64ca0d2e64de93f69803eb.mp3"></audio>
你好我是朱晔。今天我们来聊聊Spring框架中的IoC和AOP及其容易出错的地方。
熟悉Java的同学都知道Spring的家族庞大常用的模块就有Spring Data、Spring Security、Spring Boot、Spring Cloud等。其实呢Spring体系虽然庞大但都是围绕Spring Core展开的而Spring Core中最核心的就是IoC控制反转和AOP面向切面编程
概括地说IoC和AOP的初衷是解耦和扩展。理解这两个核心技术就可以让你的代码变得更灵活、可随时替换以及业务组件间更解耦。在接下来的两讲中我会与你深入剖析几个案例带你绕过业务中通过Spring实现IoC和AOP相关的坑。
为了便于理解这两讲中的案例我们先回顾下IoC和AOP的基础知识。
IoC其实就是一种设计思想。使用Spring来实现IoC意味着将你设计好的对象交给Spring容器控制而不是直接在对象内部控制。那为什么要让容器来管理对象呢或许你能想到的是使用IoC方便、可以实现解耦。但在我看来相比于这两个原因更重要的是IoC带来了更多的可能性。
如果以容器为依托来管理所有的框架、业务对象我们不仅可以无侵入地调整对象的关系还可以无侵入地随时调整对象的属性甚至是实现对象的替换。这就使得框架开发者在程序背后实现一些扩展不再是问题带来的可能性是无限的。比如我们要监控的对象如果是Bean实现就会非常简单。所以这套容器体系不仅被Spring Core和Spring Boot大量依赖还实现了一些外部框架和Spring的无缝整合。
AOP体现了松耦合、高内聚的精髓在切面集中实现横切关注点缓存、权限、日志等然后通过切点配置把代码注入合适的地方。切面、切点、增强、连接点是AOP中非常重要的概念也是我们这两讲会大量提及的。
为方便理解我们把Spring AOP技术看作为蛋糕做奶油夹层的工序。如果我们希望找到一个合适的地方把奶油注入蛋糕胚子中那应该如何指导工人完成操作呢
<img src="https://static001.geekbang.org/resource/image/c7/db/c71f2ec73901f7bcaa8332f237dfeddb.png" alt="">
- 首先我们要提醒他只能往蛋糕胚子里面加奶油而不能上面或下面加奶油。这就是连接点Join point对于Spring AOP来说连接点就是方法执行。
- 然后我们要告诉他在什么点切开蛋糕加奶油。比如可以在蛋糕坯子中间加入一层奶油在中间切一次也可以在中间加两层奶油在1/3和2/3的地方切两次。这就是切点PointcutSpring AOP中默认使用AspectJ查询表达式通过在连接点运行查询表达式来匹配切入点。
- 接下来也是最重要的我们要告诉他切开蛋糕后要做什么也就是加入奶油。这就是增强Advice也叫作通知定义了切入切点后增强的方式包括前、后、环绕等。Spring AOP中把增强定义为拦截器。
- 最后我们要告诉他找到蛋糕胚子中要加奶油的地方并加入奶油。为蛋糕做奶油夹层的操作对Spring AOP来说就是切面Aspect也叫作方面。切面=切点+增强。
好了,理解了这几个核心概念,我们就可以继续分析案例了。
我要首先说明的是Spring相关问题的问题比较复杂一方面是Spring提供的IoC和AOP本就灵活另一方面Spring Boot的自动装配、Spring Cloud复杂的模块会让问题排查变得更复杂。因此今天这一讲我会带你先打好基础通过两个案例来重点聊聊IoC和AOP然后我会在下一讲中与你分享Spring相关的坑。
## 单例的Bean如何注入Prototype的Bean
我们虽然知道Spring创建的Bean默认是单例的但当Bean遇到继承的时候可能会忽略这一点。为什么呢忽略这一点又会造成什么影响呢接下来我就和你分享一个由单例引起内存泄露的案例。
架构师一开始定义了这么一个SayService抽象类其中维护了一个类型是ArrayList的字段data用于保存方法处理的中间数据。每次调用say方法都会往data加入新数据可以认为SayService是有状态如果SayService是单例的话必然会OOM
```
@Slf4j
public abstract class SayService {
List&lt;String&gt; data = new ArrayList&lt;&gt;();
public void say() {
data.add(IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString());
log.info(&quot;I'm {} size:{}&quot;, this, data.size());
}
}
```
但实际开发的时候开发同学没有过多思考就把SayHello和SayBye类加上了@Service注解让它们成为了Bean也没有考虑到父类是有状态的
```
@Service
@Slf4j
public class SayHello extends SayService {
@Override
public void say() {
super.say();
log.info(&quot;hello&quot;);
}
}
@Service
@Slf4j
public class SayBye extends SayService {
@Override
public void say() {
super.say();
log.info(&quot;bye&quot;);
}
}
```
许多开发同学认为,@Service注解的意义在于,能通过@Autowired注解让Spring自动注入对象就比如可以直接使用注入的List<sayservice>获取到SayHello和SayBye而没想过类的生命周期</sayservice>
```
@Autowired
List&lt;SayService&gt; sayServiceList;
@GetMapping(&quot;test&quot;)
public void test() {
log.info(&quot;====================&quot;);
sayServiceList.forEach(SayService::say);
}
```
这一个点非常容易忽略。开发基类的架构师将基类设计为有状态的,但并不知道子类是怎么使用基类的;而开发子类的同学,没多想就直接标记了@Service让类成为了Bean通过@Autowired注解来注入这个服务。但这样设置后,有状态的基类就可能产生内存泄露或线程安全问题。
正确的方式是,**在为类标记上@Service注解把类型交由容器管理前首先评估一下类是否有状态然后为Bean设置合适的Scope**。好在上线前架构师发现了这个内存泄露问题开发同学也做了修改为SayHello和SayBye两个类都标记了@Scope注解设置了PROTOTYPE的生命周期也就是多例
```
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
```
但,上线后还是出现了内存泄漏,证明修改是无效的。
从日志可以看到第一次调用和第二次调用的时候SayBye对象都是4c0bfe9eSayHello也是一样的问题。从日志第7到10行还可以看到第二次调用后List的元素个数变为了2说明父类SayService维护的List在不断增长不断调用必然出现OOM
```
[15:01:09.349] [http-nio-45678-exec-1] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
[15:01:09.401] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@4c0bfe9e size:1
[15:01:09.402] [http-nio-45678-exec-1] [INFO ] [t.commonmistakes.spring.demo1.SayBye:16 ] - bye
[15:01:09.469] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@490fbeaa size:1
[15:01:09.469] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
[15:01:15.167] [http-nio-45678-exec-2] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
[15:01:15.197] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@4c0bfe9e size:2
[15:01:15.198] [http-nio-45678-exec-2] [INFO ] [t.commonmistakes.spring.demo1.SayBye:16 ] - bye
[15:01:15.224] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@490fbeaa size:2
[15:01:15.224] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
```
这就引出了单例的Bean如何注入Prototype的Bean这个问题。Controller标记了@RestController注解,而@RestController注解=@Controller注解+@ResponseBody注解,又因为@Controller标记了@Component元注解,所以@RestController注解其实也是一个Spring Bean
```
//@RestController注解=@Controller注解+@ResponseBody注解@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {}
//@Controller又标记了@Component元注解
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {}
```
**Bean默认是单例的所以单例的Controller注入的Service也是一次性创建的即使Service本身标识了prototype的范围也没用。**
修复方式是让Service以代理方式注入。这样虽然Controller本身是单例的但每次都能从代理获取Service。这样一来prototype范围的配置才能真正生效
```
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
```
通过日志可以确认这种修复方式有效:
```
[15:08:42.649] [http-nio-45678-exec-1] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
[15:08:42.747] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@3fa64743 size:1
[15:08:42.747] [http-nio-45678-exec-1] [INFO ] [t.commonmistakes.spring.demo1.SayBye:17 ] - bye
[15:08:42.871] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@2f0b779 size:1
[15:08:42.872] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
[15:08:42.932] [http-nio-45678-exec-2] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
[15:08:42.991] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@7319b18e size:1
[15:08:42.992] [http-nio-45678-exec-2] [INFO ] [t.commonmistakes.spring.demo1.SayBye:17 ] - bye
[15:08:43.046] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@77262b35 size:1
[15:08:43.046] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
```
调试一下也可以发现注入的Service都是Spring生成的代理类
<img src="https://static001.geekbang.org/resource/image/a9/30/a95f7a5f3a576b3b426c7c5625b29230.png" alt="">
当然如果不希望走代理的话还有一种方式是每次直接从ApplicationContext中获取Bean
```
@Autowired
private ApplicationContext applicationContext;
@GetMapping(&quot;test2&quot;)
public void test2() {
applicationContext.getBeansOfType(SayService.class).values().forEach(SayService::say);
}
```
如果细心的话你可以发现另一个潜在的问题。这里Spring注入的SayService的List第一个元素是SayBye第二个元素是SayHello。但我们更希望的是先执行Hello再执行Bye所以注入一个List Bean时需要进一步考虑Bean的顺序或者说优先级。
大多数情况下顺序并不是那么重要但对于AOP顺序可能会引发致命问题。我们继续往下看这个问题吧。
## 监控切面因为顺序问题导致Spring事务失效
实现横切关注点是AOP非常常见的一个应用。我曾看到过一个不错的AOP实践通过AOP实现了一个整合日志记录、异常处理和方法耗时打点为一体的统一切面。但后来发现使用了AOP切面后这个应用的声明式事务处理居然都是无效的。你可以先回顾下[第6讲](https://time.geekbang.org/column/article/213295)中提到的Spring事务失效的几种可能性。
现在我们来看下这个案例分析下AOP实现的监控组件和事务失效有什么关系以及通过AOP实现监控组件是否还有其他坑。
首先定义一个自定义注解Metrics打上了该注解的方法可以实现各种监控功能
```
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Metrics {
/**
* 在方法成功执行后打点,记录方法的执行时间发送到指标系统,默认开启
*
* @return
*/
boolean recordSuccessMetrics() default true;
/**
* 在方法成功失败后打点,记录方法的执行时间发送到指标系统,默认开启
*
* @return
*/
boolean recordFailMetrics() default true;
/**
* 通过日志记录请求参数,默认开启
*
* @return
*/
boolean logParameters() default true;
/**
* 通过日志记录方法返回值,默认开启
*
* @return
*/
boolean logReturn() default true;
/**
* 出现异常后通过日志记录异常信息,默认开启
*
* @return
*/
boolean logException() default true;
/**
* 出现异常后忽略异常返回默认值,默认关闭
*
* @return
*/
boolean ignoreException() default false;
}
```
然后实现一个切面完成Metrics注解提供的功能。这个切面可以实现标记了@RestController注解的Web控制器的自动切入如果还需要对更多Bean进行切入的话再自行标记@Metrics注解
>
备注:这段代码有些长,里面还用到了一些小技巧,你需要仔细阅读代码中的注释。
```
@Aspect
@Component
@Slf4j
public class MetricsAspect {
//让Spring帮我们注入ObjectMapper以方便通过JSON序列化来记录方法入参和出参
@Autowired
private ObjectMapper objectMapper;
//实现一个返回Java基本类型默认值的工具。其实你也可以逐一写很多if-else判断类型然后手动设置其默认值。这里为了减少代码量用了一个小技巧即通过初始化一个具有1个元素的数组然后通过获取这个数组的值来获取基本类型默认值
private static final Map&lt;Class&lt;?&gt;, Object&gt; DEFAULT_VALUES = Stream
.of(boolean.class, byte.class, char.class, double.class, float.class, int.class, long.class, short.class)
.collect(toMap(clazz -&gt; (Class&lt;?&gt;) clazz, clazz -&gt; Array.get(Array.newInstance(clazz, 1), 0)));
public static &lt;T&gt; T getDefaultValue(Class&lt;T&gt; clazz) {
return (T) DEFAULT_VALUES.get(clazz);
}
//@annotation指示器实现对标记了Metrics注解的方法进行匹配
@Pointcut(&quot;within(@org.geekbang.time.commonmistakes.springpart1.aopmetrics.Metrics *)&quot;)
public void withMetricsAnnotation() {
}
//within指示器实现了匹配那些类型上标记了@RestController注解的方法
@Pointcut(&quot;within(@org.springframework.web.bind.annotation.RestController *)&quot;)
public void controllerBean() {
}
@Around(&quot;controllerBean() || withMetricsAnnotation())&quot;)
public Object metrics(ProceedingJoinPoint pjp) throws Throwable {
//通过连接点获取方法签名和方法上Metrics注解并根据方法签名生成日志中要输出的方法定义描述
MethodSignature signature = (MethodSignature) pjp.getSignature();
Metrics metrics = signature.getMethod().getAnnotation(Metrics.class);
String name = String.format(&quot;【%s】【%s】&quot;, signature.getDeclaringType().toString(), signature.toLongString());
//因为需要默认对所有@RestController标记的Web控制器实现@Metrics注解的功能在这种情况下方法上必然是没有@Metrics注解的我们需要获取一个默认注解。虽然可以手动实例化一个@Metrics注解的实例出来但为了节省代码行数我们通过在一个内部类上定义@Metrics注解方式然后通过反射获取注解的小技巧来获得一个默认的@Metrics注解的实例
if (metrics == null) {
@Metrics
final class c {}
metrics = c.class.getAnnotation(Metrics.class);
}
//尝试从请求上下文如果有的话获得请求URL以方便定位问题
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
if (request != null)
name += String.format(&quot;【%s】&quot;, request.getRequestURL().toString());
}
//实现的是入参的日志输出
if (metrics.logParameters())
log.info(String.format(&quot;【入参日志】调用 %s 的参数是:【%s】&quot;, name, objectMapper.writeValueAsString(pjp.getArgs())));
//实现连接点方法的执行,以及成功失败的打点,出现异常的时候还会记录日志
Object returnValue;
Instant start = Instant.now();
try {
returnValue = pjp.proceed();
if (metrics.recordSuccessMetrics())
//在生产级代码中我们应考虑使用类似Micrometer的指标框架把打点信息记录到时间序列数据库中实现通过图表来查看方法的调用次数和执行时间在设计篇我们会重点介绍
log.info(String.format(&quot;【成功打点】调用 %s 成功,耗时:%d ms&quot;, name, Duration.between(start, Instant.now()).toMillis()));
} catch (Exception ex) {
if (metrics.recordFailMetrics())
log.info(String.format(&quot;【失败打点】调用 %s 失败,耗时:%d ms&quot;, name, Duration.between(start, Instant.now()).toMillis()));
if (metrics.logException())
log.error(String.format(&quot;【异常日志】调用 %s 出现异常!&quot;, name), ex);
//忽略异常的时候使用一开始定义的getDefaultValue方法来获取基本类型的默认值
if (metrics.ignoreException())
returnValue = getDefaultValue(signature.getReturnType());
else
throw ex;
}
//实现了返回值的日志输出
if (metrics.logReturn())
log.info(String.format(&quot;【出参日志】调用 %s 的返回是:【%s】&quot;, name, returnValue));
return returnValue;
}
}
```
接下来分别定义最简单的Controller、Service和Repository来测试MetricsAspect的功能。
其中Service中实现创建用户的时候做了事务处理当用户名包含test字样时会抛出异常导致事务回滚。同时我们为Service中的createUser标记了@Metrics注解。这样一来,我们还可以手动为类或方法标记@Metrics注解实现Controller之外的其他组件的自动监控。
```
@Slf4j
@RestController //自动进行监控
@RequestMapping(&quot;metricstest&quot;)
public class MetricsController {
@Autowired
private UserService userService;
@GetMapping(&quot;transaction&quot;)
public int transaction(@RequestParam(&quot;name&quot;) String name) {
try {
userService.createUser(new UserEntity(name));
} catch (Exception ex) {
log.error(&quot;create user failed because {}&quot;, ex.getMessage());
}
return userService.getUserCount(name);
}
}
@Service
@Slf4j
public class UserService {
@Autowired
private UserRepository userRepository;
@Transactional
@Metrics //启用方法监控
public void createUser(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains(&quot;test&quot;))
throw new RuntimeException(&quot;invalid username!&quot;);
}
public int getUserCount(String name) {
return userRepository.findByName(name).size();
}
}
@Repository
public interface UserRepository extends JpaRepository&lt;UserEntity, Long&gt; {
List&lt;UserEntity&gt; findByName(String name);
}
```
使用用户名“test”测试一下注册功能
```
[16:27:52.586] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :85 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的参数是:【[&quot;test&quot;]】
[16:27:52.590] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :85 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 的参数是:【[{&quot;id&quot;:null,&quot;name&quot;:&quot;test&quot;}]】
[16:27:52.609] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :96 ] - 【失败打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 失败耗时19 ms
[16:27:52.610] [http-nio-45678-exec-3] [ERROR] [o.g.t.c.spring.demo2.MetricsAspect :98 ] - 【异常日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 出现异常!
java.lang.RuntimeException: invalid username!
at org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(UserService.java:18)
at org.geekbang.time.commonmistakes.spring.demo2.UserService$$FastClassBySpringCGLIB$$9eec91f.invoke(&lt;generated&gt;)
[16:27:52.614] [http-nio-45678-exec-3] [ERROR] [g.t.c.spring.demo2.MetricsController:21 ] - create user failed because invalid username!
[16:27:52.617] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :93 ] - 【成功打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 成功耗时31 ms
[16:27:52.618] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :108 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的返回是【0】
```
看起来这个切面很不错,日志中打出了整个调用的出入参、方法耗时:
- 第1、8、9和10行分别是Controller方法的入参日志、调用Service方法出错后记录的错误信息、成功执行的打点和出参日志。因为Controller方法内部进行了try-catch处理所以其方法最终是成功执行的。出参日志中显示最后查询到的用户数量是0表示用户创建实际是失败的。
- 第2、3和4~7行分别是Service方法的入参日志、失败打点和异常日志。正是因为Service方法的异常抛到了Controller所以整个方法才能被@Transactional声明式事务回滚。在这里MetricsAspect捕获了异常又重新抛出记录了异常的同时又不影响事务回滚。
一段时间后,开发同学觉得默认的@Metrics配置有点不合适,希望进行两个调整:
- 对于Controller的自动打点不要自动记录入参和出参日志否则日志量太大
- 对于Service中的方法最好可以自动捕获异常。
于是他就为MetricsController手动加上了@Metrics注解设置logParameters和logReturn为false然后为Service中的createUser方法的@Metrics注解设置了ignoreException属性为true
```
@Metrics(logParameters = false, logReturn = false) //改动点1
public class MetricsController {
@Service
@Slf4j
public class UserService {
@Transactional
@Metrics(ignoreException = true) //改动点2
public void createUser(UserEntity entity) {
...
```
代码上线后发现日志量并没有减少更要命的是事务回滚失效了从输出看到最后查询到了名为test的用户
```
[17:01:16.549] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :75 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的参数是:【[&quot;test&quot;]】
[17:01:16.670] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :75 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 的参数是:【[{&quot;id&quot;:null,&quot;name&quot;:&quot;test&quot;}]】
[17:01:16.885] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :86 ] - 【失败打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 失败耗时211 ms
[17:01:16.899] [http-nio-45678-exec-1] [ERROR] [o.g.t.c.spring.demo2.MetricsAspect :88 ] - 【异常日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 出现异常!
java.lang.RuntimeException: invalid username!
at org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(UserService.java:18)
at org.geekbang.time.commonmistakes.spring.demo2.UserService$$FastClassBySpringCGLIB$$9eec91f.invoke(&lt;generated&gt;)
[17:01:16.902] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :98 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 的返回是【null】
[17:01:17.466] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :83 ] - 【成功打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 成功耗时915 ms
[17:01:17.467] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :98 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的返回是【1】
```
在介绍[数据库事务](https://time.geekbang.org/column/article/213295)时我们分析了Spring通过TransactionAspectSupport类实现事务。在invokeWithinTransaction方法中设置断点可以发现在执行Service的createUser方法时TransactionAspectSupport并没有捕获到异常所以自然无法回滚事务。原因就是**异常被MetricsAspect吃掉了**。
我们知道切面本身是一个BeanSpring对不同切面增强的执行顺序是由Bean优先级决定的具体规则是
- 入操作Around连接点执行前、Before切面优先级越高越先执行。一个切面的入操作执行完才轮到下一切面所有切面入操作执行完才开始执行连接点方法
- 出操作Around连接点执行后、After、AfterReturning、AfterThrowing切面优先级越低越先执行。一个切面的出操作执行完才轮到下一切面直到返回到调用点。
- 同一切面的Around比After、Before先执行。
对于Bean可以通过@Order注解来设置优先级,查看@Order注解和Ordered接口源码可以发现默认情况下Bean的优先级为最低优先级其值是Integer的最大值。其实**值越大优先级反而越低,这点比较反直觉**
```
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
int value() default Ordered.LOWEST_PRECEDENCE;
}
public interface Ordered {
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
int getOrder();
}
```
我们再通过一个例子来理解下增强的执行顺序。新建一个TestAspectWithOrder10切面通过@Order注解设置优先级为10,在内部定义@Before@After@Around三类增强三个增强的逻辑只是简单的日志输出切点是TestController所有方法然后再定义一个类似的TestAspectWithOrder20切面设置优先级为20
```
@Aspect
@Component
@Order(10)
@Slf4j
public class TestAspectWithOrder10 {
@Before(&quot;execution(* org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.*(..))&quot;)
public void before(JoinPoint joinPoint) throws Throwable {
log.info(&quot;TestAspectWithOrder10 @Before&quot;);
}
@After(&quot;execution(* org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.*(..))&quot;)
public void after(JoinPoint joinPoint) throws Throwable {
log.info(&quot;TestAspectWithOrder10 @After&quot;);
}
@Around(&quot;execution(* org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.*(..))&quot;)
public Object around(ProceedingJoinPoint pjp) throws Throwable {
log.info(&quot;TestAspectWithOrder10 @Around before&quot;);
Object o = pjp.proceed();
log.info(&quot;TestAspectWithOrder10 @Around after&quot;);
return o;
}
}
@Aspect
@Component
@Order(20)
@Slf4j
public class TestAspectWithOrder20 {
...
}
```
调用TestController的方法后通过日志输出可以看到增强执行顺序符合切面执行顺序的三个规则
<img src="https://static001.geekbang.org/resource/image/3c/3e/3c687829083abebe1d6e347f5766903e.png" alt="">
因为Spring的事务管理也是基于AOP的默认情况下优先级最低也就是会先执行出操作但是自定义切面MetricsAspect也同样是最低优先级这个时候就可能出现问题如果出操作先执行捕获了异常那么Spring的事务处理就会因为无法捕获到异常导致无法回滚事务。
解决方式是明确MetricsAspect的优先级可以设置为最高优先级也就是最先执行入操作最后执行出操作
```
//将MetricsAspect这个Bean的优先级设置为最高
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MetricsAspect {
...
}
```
此外,**我们要知道切入的连接点是方法,注解定义在类上是无法直接从方法上获取到注解的**。修复方式是,改为优先从方法获取,如果获取不到再从类获取,如果还是获取不到再使用默认的注解:
```
Metrics metrics = signature.getMethod().getAnnotation(Metrics.class);
if (metrics == null) {
metrics = signature.getMethod().getDeclaringClass().getAnnotation(Metrics.class);
}
```
经过这2处修改事务终于又可以回滚了并且Controller的监控日志也不再出现入参、出参信息。
我再总结下这个案例。利用反射+注解+Spring AOP实现统一的横切日志关注点时我们遇到的Spring事务失效问题是由自定义的切面执行顺序引起的。这也让我们认识到因为Spring内部大量利用IoC和AOP实现了各种组件当使用IoC和AOP时一定要考虑是否会影响其他内部组件。
## 重点回顾
今天我通过2个案例和你分享了Spring IoC和AOP的基本概念以及三个比较容易出错的点。
第一让Spring容器管理对象要考虑对象默认的Scope单例是否适合对于有状态的类型单例可能产生内存泄露问题。
第二如果要为单例的Bean注入Prototype的Bean绝不是仅仅修改Scope属性这么简单。由于单例的Bean在容器启动时就会完成一次性初始化。最简单的解决方案是把Prototype的Bean设置为通过代理注入也就是设置proxyMode属性为TARGET_CLASS。
第三如果一组相同类型的Bean是有顺序的需要明确使用@Order注解来设置顺序。你可以再回顾下,两个不同优先级切面中@Before@After和@Around三种增强的执行顺序,是什么样的。
最后我要说的是文内第二个案例是一个完整的统一日志监控案例继续修改就可以实现一个完善的、生产级的方法调用监控平台。这些修改主要是两方面把日志打点改为对接Metrics监控系统把各种功能的监控开关从注解属性获取改为通过配置系统实时获取。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 除了通过@Autowired注入Bean外,还可以使用@Inject或@Resource来注入Bean。你知道这三种方式的区别是什么吗?
1. 当Bean产生循环依赖时比如BeanA的构造方法依赖BeanB作为成员需要注入BeanB也依赖BeanA你觉得会出现什么问题呢又有哪些解决方式呢
在下一讲中我会继续与你探讨Spring核心的其他问题。我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,654 @@
<audio id="audio" title="20 | Spring框架框架帮我们做了很多工作也带来了复杂度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/d6/e1fd22aa528fd1a2971d5e5e2199a7d6.mp3"></audio>
你好我是朱晔。今天我们聊聊Spring框架给业务代码带来的复杂度以及与之相关的坑。
在上一讲通过AOP实现统一的监控组件的案例我们看到了IoC和AOP配合使用的威力当对象由Spring容器管理成为Bean之后我们不但可以通过容器管理配置Bean的属性还可以方便地对感兴趣的方法做AOP。
不过前提是对象必须是Bean。你可能会觉得这个结论很明显也很容易理解啊。但就和上一讲提到的Bean默认是单例一样理解起来简单实践的时候却非常容易踩坑。其中原因一方面是理解Spring的体系结构和使用方式有一定曲线另一方面是Spring多年发展堆积起来的内部结构非常复杂这也是更重要的原因。
在我看来Spring框架内部的复杂度主要表现为三点
- 第一Spring框架借助IoC和AOP的功能实现了修改、拦截Bean的定义和实例的灵活性因此真正执行的代码流程并不是串行的。
- 第二Spring Boot根据当前依赖情况实现了自动配置虽然省去了手动配置的麻烦但也因此多了一些黑盒、提升了复杂度。
- 第三Spring Cloud模块多版本也多Spring Boot 1.x和2.x的区别也很大。如果要对Spring Cloud或Spring Boot进行二次开发的话考虑兼容性的成本会很高。
今天我们就通过配置AOP切入Spring Cloud Feign组件失败、Spring Boot程序的文件配置被覆盖这两个案例感受一下Spring的复杂度。我希望这一讲的内容能帮助你面对Spring这个复杂框架出现的问题时可以非常自信地找到解决方案。
## Feign AOP切不到的诡异案例
我曾遇到过这么一个案例使用Spring Cloud做微服务调用为方便统一处理Feign想到了用AOP实现即使用within指示器匹配feign.Client接口的实现进行AOP切入。
代码如下,通过@Before注解在执行方法前打印日志,并在代码中定义了一个标记了@FeignClient注解的Client类让其成为一个Feign接口
```
//测试Feign
@FeignClient(name = &quot;client&quot;)
public interface Client {
@GetMapping(&quot;/feignaop/server&quot;)
String api();
}
//AOP切入feign.Client的实现
@Aspect
@Slf4j
@Component
public class WrongAspect {
@Before(&quot;within(feign.Client+)&quot;)
public void before(JoinPoint pjp) {
log.info(&quot;within(feign.Client+) pjp {}, args:{}&quot;, pjp, pjp.getArgs());
}
}
//配置扫描Feign
@Configuration
@EnableFeignClients(basePackages = &quot;org.geekbang.time.commonmistakes.spring.demo4.feign&quot;)
public class Config {
}
```
通过Feign调用服务后可以看到日志中有输出的确实现了feign.Client的切入切入的是execute方法
```
[15:48:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect :20 ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://client/feignaop/server HTTP/1.1
Binary data, feign.Request$Options@5c16561a]
```
一开始这个项目使用的是客户端的负载均衡也就是让Ribbon来做负载均衡代码没啥问题。后来因为后端服务通过Nginx实现服务端负载均衡所以开发同学把@FeignClient的配置设置了URL属性直接通过一个固定URL调用后端服务
```
@FeignClient(name = &quot;anotherClient&quot;,url = &quot;http://localhost:45678&quot;)
public interface ClientWithUrl {
@GetMapping(&quot;/feignaop/server&quot;)
String api();
}
```
但这样配置后之前的AOP切面竟然失效了也就是within(feign.Client+)无法切入ClientWithUrl的调用了。
为了还原这个场景我写了一段代码定义两个方法分别通过Client和ClientWithUrl这两个Feign进行接口调用
```
@Autowired
private Client client;
@Autowired
private ClientWithUrl clientWithUrl;
@GetMapping(&quot;client&quot;)
public String client() {
return client.api();
}
@GetMapping(&quot;clientWithUrl&quot;)
public String clientWithUrl() {
return clientWithUrl.api();
}
```
可以看到调用Client后AOP有日志输出调用ClientWithUrl后却没有
```
[15:50:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect :20 ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://client/feignaop/server HTTP/1.1
Binary data, feign.Request$Options@5c16561
```
这就很费解了。难道为Feign指定了URL其实现就不是feign.Clinet了吗
要明白原因我们需要分析一下FeignClient的创建过程也就是分析FeignClientFactoryBean类的getTarget方法。源码第4行有一个if判断当URL没有内容也就是为空或者不配置时调用loadBalance方法在其内部通过FeignContext从容器获取feign.Client的实例
```
&lt;T&gt; T getTarget() {
FeignContext context = this.applicationContext.getBean(FeignContext.class);
Feign.Builder builder = feign(context);
if (!StringUtils.hasText(this.url)) {
...
return (T) loadBalance(builder, context,
new HardCodedTarget&lt;&gt;(this.type, this.name, this.url));
}
...
String url = this.url + cleanPath();
Client client = getOptional(context, Client.class);
if (client != null) {
if (client instanceof LoadBalancerFeignClient) {
// not load balancing because we have a url,
// but ribbon is on the classpath, so unwrap
client = ((LoadBalancerFeignClient) client).getDelegate();
}
builder.client(client);
}
...
}
protected &lt;T&gt; T loadBalance(Feign.Builder builder, FeignContext context,
HardCodedTarget&lt;T&gt; target) {
Client client = getOptional(context, Client.class);
if (client != null) {
builder.client(client);
Targeter targeter = get(context, Targeter.class);
return targeter.target(this, builder, context, target);
}
...
}
protected &lt;T&gt; T getOptional(FeignContext context, Class&lt;T&gt; type) {
return context.getInstance(this.contextId, type);
}
```
调试一下可以看到client是LoadBalanceFeignClient已经是经过代理增强的明显是一个Bean
<img src="https://static001.geekbang.org/resource/image/05/fd/0510e28cd764aaf7f1b4b4ca03049ffd.png" alt="">
所以没有指定URL的@FeignClient对应的LoadBalanceFeignClient是可以通过feign.Client切入的。
在我们上面贴出来的源码的16行可以看到当URL不为空的时候client设置为了LoadBalanceFeignClient的delegate属性。其原因注释中有提到因为有了URL就不需要客户端负载均衡了但因为Ribbon在classpath中所以需要从LoadBalanceFeignClient提取出真正的Client。断点调试下可以看到这时client是一个ApacheHttpClient
<img src="https://static001.geekbang.org/resource/image/1b/30/1b872a900be7327f74bc09bde4c54230.png" alt="">
那么这个ApacheHttpClient是从哪里来的呢这里我教你一个小技巧如果你希望知道一个类是怎样调用栈初始化的可以在构造方法中设置一个断点进行调试。这样你就可以在IDE的栈窗口看到整个方法调用栈然后点击每一个栈帧看到整个过程。
用这种方式我们可以看到是HttpClientFeignLoadBalancedConfiguration类实例化的ApacheHttpClient
<img src="https://static001.geekbang.org/resource/image/7b/9a/7b712acf6d7062ae82f1fd04b954ff9a.png" alt="">
进一步查看HttpClientFeignLoadBalancedConfiguration的源码可以发现LoadBalancerFeignClient这个Bean在实例化的时候new出来一个ApacheHttpClient作为delegate放到了LoadBalancerFeignClient中
```
@Bean
@ConditionalOnMissingBean(Client.class)
public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory,
SpringClientFactory clientFactory, HttpClient httpClient) {
ApacheHttpClient delegate = new ApacheHttpClient(httpClient);
return new LoadBalancerFeignClient(delegate, cachingFactory, clientFactory);
}
public LoadBalancerFeignClient(Client delegate,
CachingSpringLoadBalancerFactory lbClientFactory,
SpringClientFactory clientFactory) {
this.delegate = delegate;
this.lbClientFactory = lbClientFactory;
this.clientFactory = clientFactory;
}
```
显然ApacheHttpClient是new出来的并不是Bean而LoadBalancerFeignClient是一个Bean。
有了这个信息我们再来捋一下为什么within(feign.Client+)无法切入设置过URL的@FeignClient ClientWithUrl
- 表达式声明的是切入feign.Client的实现类。
- Spring只能切入由自己管理的Bean。
- **虽然LoadBalancerFeignClient和ApacheHttpClient都是feign.Client接口的实现但是HttpClientFeignLoadBalancedConfiguration的自动配置只是把前者定义为Bean后者是new出来的、作为了LoadBalancerFeignClient的delegate不是Bean**。
- 在定义了FeignClient的URL属性后我们获取的是LoadBalancerFeignClient的delegate它不是Bean。
因此定义了URL的FeignClient采用within(feign.Client+)无法切入。
那,如何解决这个问题呢?有一位同学提出,修改一下切点表达式,通过@FeignClient注解来切
```
@Before(&quot;@within(org.springframework.cloud.openfeign.FeignClient)&quot;)
public void before(JoinPoint pjp){
log.info(&quot;@within(org.springframework.cloud.openfeign.FeignClient) pjp {}, args:{}&quot;, pjp, pjp.getArgs());
}
```
修改后通过日志看到AOP的确切成功了
```
[15:53:39.093] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo4.Wrong2Aspect :17 ] - @within(org.springframework.cloud.openfeign.FeignClient) pjp execution(String org.geekbang.time.commonmistakes.spring.demo4.feign.ClientWithUrl.api()), args:[]
```
但仔细一看就会发现,**这次切入的是ClientWithUrl接口的API方法并不是client.Feign接口的execute方法显然不符合预期**。
这位同学犯的错误是,没有弄清楚真正希望切的是什么对象。@FeignClient注解标记在Feign Client接口上所以切的是Feign定义的接口也就是每一个实际的API接口。而通过feign.Client接口切的是客户端实现类切到的是通用的、执行所有Feign调用的execute方法。
那么问题来了ApacheHttpClient不是Bean无法切入切Feign接口本身又不符合要求。怎么办呢
经过一番研究发现ApacheHttpClient其实有机会独立成为Bean。查看HttpClientFeignConfiguration的源码可以发现当没有ILoadBalancer类型的时候自动装配会把ApacheHttpClient设置为Bean。
这么做的原因很明确如果我们不希望做客户端负载均衡的话应该不会引用Ribbon组件的依赖自然没有LoadBalancerFeignClient只有ApacheHttpClient
```
@Configuration
@ConditionalOnClass(ApacheHttpClient.class)
@ConditionalOnMissingClass(&quot;com.netflix.loadbalancer.ILoadBalancer&quot;)
@ConditionalOnMissingBean(CloseableHttpClient.class)
@ConditionalOnProperty(value = &quot;feign.httpclient.enabled&quot;, matchIfMissing = true)
protected static class HttpClientFeignConfiguration {
@Bean
@ConditionalOnMissingBean(Client.class)
public Client feignClient(HttpClient httpClient) {
return new ApacheHttpClient(httpClient);
}
}
```
把pom.xml中的ribbon模块注释之后是不是可以解决问题呢
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;
&lt;artifactId&gt;spring-cloud-starter-netflix-ribbon&lt;/artifactId&gt;
&lt;/dependency&gt;
```
但,问题并没解决,启动出错误了:
```
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class feign.httpclient.ApacheHttpClient
at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:657)
at org.springframework.cglib.core.DefaultGeneratorStrategy.generate(DefaultGeneratorStrategy.java:25)
```
这里又涉及了Spring实现动态代理的两种方式
- JDK动态代理通过反射实现只支持对实现接口的类进行代理
- CGLIB动态字节码注入方式通过继承实现代理没有这个限制。
**Spring Boot 2.x默认使用CGLIB的方式但通过继承实现代理有个问题是无法继承final的类。因为ApacheHttpClient类就是定义为了final**
```
public final class ApacheHttpClient implements Client {
```
为解决这个问题我们把配置参数proxy-target-class的值修改为false以切换到使用JDK动态代理的方式
```
spring.aop.proxy-target-class=false
```
修改后执行clientWithUrl接口可以看到通过within(feign.Client+)方式可以切入feign.Client子类了。以下日志显示了@within和within的两次切入
```
[16:29:55.303] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.Wrong2Aspect :16 ] - @within(org.springframework.cloud.openfeign.FeignClient) pjp execution(String org.geekbang.time.commonmistakes.spring.demo4.feign.ClientWithUrl.api()), args:[]
[16:29:55.310] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect :15 ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://localhost:45678/feignaop/server HTTP/1.1
Binary data, feign.Request$Options@387550b0]
```
这下我们就明白了Spring Cloud使用了自动装配来根据依赖装配组件组件是否成为Bean决定了AOP是否可以切入在尝试通过AOP切入Spring Bean的时候要注意。
加上上一讲的两个案例我就把IoC和AOP相关的坑点和你说清楚了。除此之外我们在业务开发时还有一个绕不开的点是Spring程序的配置问题。接下来我们就具体看看吧。
## Spring程序配置的优先级问题
我们知道通过配置文件application.properties可以实现Spring Boot应用程序的参数配置。但我们可能不知道的是Spring程序配置是有优先级的即当两个不同的配置源包含相同的配置项时其中一个配置项很可能会被覆盖掉。这也是为什么我们会遇到些看似诡异的配置失效问题。
我们来通过一个实际案例,研究下配置源以及配置源的优先级问题。
对于Spring Boot应用程序一般我们会通过设置management.server.port参数来暴露独立的actuator管理端口。这样做更安全也更方便监控系统统一监控程序是否健康。
```
management.server.port=45679
```
有一天程序重新发布后监控系统显示程序离线。但排查下来发现程序是正常工作的只是actuator管理端口的端口号被改了不是配置文件中定义的45679了。
后来发现运维同学在服务器上定义了两个环境变量MANAGEMENT_SERVER_IP和MANAGEMENT_SERVER_PORT目的是方便监控Agent把监控数据上报到统一的管理服务上
```
MANAGEMENT_SERVER_IP=192.168.0.2
MANAGEMENT_SERVER_PORT=12345
```
问题就是出在这里。MANAGEMENT_SERVER_PORT覆盖了配置文件中的management.server.port修改了应用程序本身的端口。当然监控系统也就无法通过老的管理端口访问到应用的health端口了。如下图所示actuator的端口号变成了12345
<img src="https://static001.geekbang.org/resource/image/b2/e6/b287b7ad823a39bb604fa69e02c720e6.png" alt="">
到这里坑还没完为了方便用户登录需要在页面上显示默认的管理员用户名于是开发同学在配置文件中定义了一个user.name属性并设置为defaultadminname
```
user.name=defaultadminname
```
后来发现,程序读取出来的用户名根本就不是配置文件中定义的。这,又是咋回事?
带着这个问题以及之前环境变量覆盖配置文件配置的问题我们写段代码看看从Spring中到底能读取到几个management.server.port和user.name配置项。
要想查询Spring中所有的配置我们需要以环境Environment接口为入口。接下来我就与你说说Spring通过环境Environment抽象出的Property和Profile
- 针对Property又抽象出各种PropertySource类代表配置源。一个环境下可能有多个配置源每个配置源中有诸多配置项。在查询配置信息时需要按照配置源优先级进行查询。
- Profile定义了场景的概念。通常我们会定义类似dev、test、stage和prod等环境作为不同的Profile用于按照场景对Bean进行逻辑归属。同时Profile和配置文件也有关系每个环境都有独立的配置文件但我们只会激活某一个环境来生效特定环境的配置文件。
<img src="https://static001.geekbang.org/resource/image/2c/c0/2c68da94d31182cad34c965f878196c0.png" alt="">
接下来我们重点看看Property的查询过程。
对于非Web应用Spring对于Environment接口的实现是StandardEnvironment类。我们通过Spring注入StandardEnvironment后循环getPropertySources获得的PropertySource来查询所有的PropertySource中key是user.name或management.server.port的属性值然后遍历getPropertySources方法获得所有配置源并打印出来
```
@Autowired
private StandardEnvironment env;
@PostConstruct
public void init(){
Arrays.asList(&quot;user.name&quot;, &quot;management.server.port&quot;).forEach(key -&gt; {
env.getPropertySources().forEach(propertySource -&gt; {
if (propertySource.containsProperty(key)) {
log.info(&quot;{} -&gt; {} 实际取值:{}&quot;, propertySource, propertySource.getProperty(key), env.getProperty(key));
}
});
});
System.out.println(&quot;配置优先级:&quot;);
env.getPropertySources().stream().forEach(System.out::println);
}
```
我们研究下输出的日志:
```
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : ConfigurationPropertySourcesPropertySource {name='configurationProperties'} -&gt; zhuye 实际取值zhuye
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : PropertiesPropertySource {name='systemProperties'} -&gt; zhuye 实际取值zhuye
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'} -&gt; defaultadminname 实际取值zhuye
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : ConfigurationPropertySourcesPropertySource {name='configurationProperties'} -&gt; 12345 实际取值12345
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : OriginAwareSystemEnvironmentPropertySource {name=''} -&gt; 12345 实际取值12345
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'} -&gt; 45679 实际取值12345
配置优先级:
ConfigurationPropertySourcesPropertySource {name='configurationProperties'}
StubPropertySource {name='servletConfigInitParams'}
ServletContextPropertySource {name='servletContextInitParams'}
PropertiesPropertySource {name='systemProperties'}
OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}
RandomValuePropertySource {name='random'}
OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'}
MapPropertySource {name='springCloudClientHostInfo'}
MapPropertySource {name='defaultProperties'}
```
- 有三处定义了user.name第一个是configurationProperties值是zhuye第二个是systemProperties代表系统配置值是zhuye第三个是applicationConfig也就是我们的配置文件值是配置文件中定义的defaultadminname。
- 同样地也有三处定义了management.server.port第一个是configurationProperties值是12345第二个是systemEnvironment代表系统环境值是12345第三个是applicationConfig也就是我们的配置文件值是配置文件中定义的45679。
- 第7到16行的输出显示Spring中有9个配置源值得关注是ConfigurationPropertySourcesPropertySource、PropertiesPropertySource、OriginAwareSystemEnvironmentPropertySource和我们的配置文件。
那么Spring真的是按这个顺序查询配置吗最前面的configurationProperties又是什么为了回答这2个问题我们需要分析下源码。我先说明下下面源码分析的逻辑有些复杂你可以结合着下面的整体流程图来理解
<img src="https://static001.geekbang.org/resource/image/3e/f9/3e6dc6456f6d1354da58fb260775c0f9.png" alt="">
Demo中注入的StandardEnvironment继承的是AbstractEnvironment图中紫色类。AbstractEnvironment的源码如下
```
public abstract class AbstractEnvironment implements ConfigurableEnvironment {
private final MutablePropertySources propertySources = new MutablePropertySources();
private final ConfigurablePropertyResolver propertyResolver =
new PropertySourcesPropertyResolver(this.propertySources);
public String getProperty(String key) {
return this.propertyResolver.getProperty(key);
}
}
```
可以看到:
- MutablePropertySources类型的字段propertySources看起来代表了所有配置源
- getProperty方法通过PropertySourcesPropertyResolver类进行查询配置
- 实例化PropertySourcesPropertyResolver的时候传入了当前的MutablePropertySources。
接下来我们继续分析MutablePropertySources和PropertySourcesPropertyResolver。先看看MutablePropertySources的源码图中蓝色类
```
public class MutablePropertySources implements PropertySources {
private final List&lt;PropertySource&lt;?&gt;&gt; propertySourceList = new CopyOnWriteArrayList&lt;&gt;();
public void addFirst(PropertySource&lt;?&gt; propertySource) {
removeIfPresent(propertySource);
this.propertySourceList.add(0, propertySource);
}
public void addLast(PropertySource&lt;?&gt; propertySource) {
removeIfPresent(propertySource);
this.propertySourceList.add(propertySource);
}
public void addBefore(String relativePropertySourceName, PropertySource&lt;?&gt; propertySource) {
...
int index = assertPresentAndGetIndex(relativePropertySourceName);
addAtIndex(index, propertySource);
}
public void addAfter(String relativePropertySourceName, PropertySource&lt;?&gt; propertySource) {
...
int index = assertPresentAndGetIndex(relativePropertySourceName);
addAtIndex(index + 1, propertySource);
}
private void addAtIndex(int index, PropertySource&lt;?&gt; propertySource) {
removeIfPresent(propertySource);
this.propertySourceList.add(index, propertySource);
}
}
```
可以发现:
- propertySourceList字段用来真正保存PropertySource的List且这个List是一个CopyOnWriteArrayList。
- 类中定义了addFirst、addLast、addBefore、addAfter等方法来精确控制PropertySource加入propertySourceList的顺序。这也说明了顺序的重要性。
继续看下PropertySourcesPropertyResolver图中绿色类的源码找到真正查询配置的方法getProperty。
这里我们重点看一下第9行代码遍历的propertySources是PropertySourcesPropertyResolver构造方法传入的再结合AbstractEnvironment的源码可以发现这个propertySources正是AbstractEnvironment中的MutablePropertySources对象。遍历时如果发现配置源中有对应的Key值则使用这个值。因此MutablePropertySources中配置源的次序尤为重要。
```
public class PropertySourcesPropertyResolver extends AbstractPropertyResolver {
private final PropertySources propertySources;
public PropertySourcesPropertyResolver(@Nullable PropertySources propertySources) {
this.propertySources = propertySources;
}
protected &lt;T&gt; T getProperty(String key, Class&lt;T&gt; targetValueType, boolean resolveNestedPlaceholders) {
if (this.propertySources != null) {
for (PropertySource&lt;?&gt; propertySource : this.propertySources) {
if (logger.isTraceEnabled()) {
logger.trace(&quot;Searching for key '&quot; + key + &quot;' in PropertySource '&quot; +
propertySource.getName() + &quot;'&quot;);
}
Object value = propertySource.getProperty(key);
if (value != null) {
if (resolveNestedPlaceholders &amp;&amp; value instanceof String) {
value = resolveNestedPlaceholders((String) value);
}
logKeyFound(key, propertySource, value);
return convertValueIfNecessary(value, targetValueType);
}
}
}
...
}
}
```
回到之前的问题在查询所有配置源的时候我们注意到处在第一位的是ConfigurationPropertySourcesPropertySource这是什么呢
其实它不是一个实际存在的配置源扮演的是一个代理的角色。但通过调试你会发现我们获取的值竟然是由它提供并且返回的且没有循环遍历后面的PropertySource
<img src="https://static001.geekbang.org/resource/image/73/fb/7380c93e743e3fc41d8cc58b77895bfb.png" alt="">
继续查看ConfigurationPropertySourcesPropertySource图中红色类的源码可以发现getProperty方法其实是通过findConfigurationProperty方法查询配置的。如第25行代码所示这其实还是在遍历所有的配置源
```
class ConfigurationPropertySourcesPropertySource extends PropertySource&lt;Iterable&lt;ConfigurationPropertySource&gt;&gt;
implements OriginLookup&lt;String&gt; {
ConfigurationPropertySourcesPropertySource(String name, Iterable&lt;ConfigurationPropertySource&gt; source) {
super(name, source);
}
@Override
public Object getProperty(String name) {
ConfigurationProperty configurationProperty = findConfigurationProperty(name);
return (configurationProperty != null) ? configurationProperty.getValue() : null;
}
private ConfigurationProperty findConfigurationProperty(String name) {
try {
return findConfigurationProperty(ConfigurationPropertyName.of(name, true));
}
catch (Exception ex) {
return null;
}
}
private ConfigurationProperty findConfigurationProperty(ConfigurationPropertyName name) {
if (name == null) {
return null;
}
for (ConfigurationPropertySource configurationPropertySource : getSource()) {
ConfigurationProperty configurationProperty = configurationPropertySource.getConfigurationProperty(name);
if (configurationProperty != null) {
return configurationProperty;
}
}
return null;
}
}
```
调试可以发现这个循环遍历getSource()的结果的配置源其实是SpringConfigurationPropertySources图中黄色类其中包含的配置源列表就是之前看到的9个配置源而第一个就是ConfigurationPropertySourcesPropertySource。看到这里我们的第一感觉是会不会产生死循环它在遍历的时候怎么排除自己呢
同时观察configurationProperty可以看到这个ConfigurationProperty其实类似代理的角色实际配置是从系统属性中获得的
<img src="https://static001.geekbang.org/resource/image/95/0a/9551d5b5acada84262b7ddeae989750a.png" alt="">
继续查看SpringConfigurationPropertySources可以发现它返回的迭代器是内部类SourcesIterator在fetchNext方法获取下一个项时通过isIgnored方法排除了ConfigurationPropertySourcesPropertySource源码第38行
```
class SpringConfigurationPropertySources implements Iterable&lt;ConfigurationPropertySource&gt; {
private final Iterable&lt;PropertySource&lt;?&gt;&gt; sources;
private final Map&lt;PropertySource&lt;?&gt;, ConfigurationPropertySource&gt; cache = new ConcurrentReferenceHashMap&lt;&gt;(16,
ReferenceType.SOFT);
SpringConfigurationPropertySources(Iterable&lt;PropertySource&lt;?&gt;&gt; sources) {
Assert.notNull(sources, &quot;Sources must not be null&quot;);
this.sources = sources;
}
@Override
public Iterator&lt;ConfigurationPropertySource&gt; iterator() {
return new SourcesIterator(this.sources.iterator(), this::adapt);
}
private static class SourcesIterator implements Iterator&lt;ConfigurationPropertySource&gt; {
@Override
public boolean hasNext() {
return fetchNext() != null;
}
private ConfigurationPropertySource fetchNext() {
if (this.next == null) {
if (this.iterators.isEmpty()) {
return null;
}
if (!this.iterators.peek().hasNext()) {
this.iterators.pop();
return fetchNext();
}
PropertySource&lt;?&gt; candidate = this.iterators.peek().next();
if (candidate.getSource() instanceof ConfigurableEnvironment) {
push((ConfigurableEnvironment) candidate.getSource());
return fetchNext();
}
if (isIgnored(candidate)) {
return fetchNext();
}
this.next = this.adapter.apply(candidate);
}
return this.next;
}
private void push(ConfigurableEnvironment environment) {
this.iterators.push(environment.getPropertySources().iterator());
}
private boolean isIgnored(PropertySource&lt;?&gt; candidate) {
return (candidate instanceof StubPropertySource
|| candidate instanceof ConfigurationPropertySourcesPropertySource);
}
}
}
```
我们已经了解了ConfigurationPropertySourcesPropertySource是所有配置源中的第一个实现了对PropertySourcesPropertyResolver中遍历逻辑的“劫持”并且知道了其遍历逻辑。最后一个问题是它如何让自己成为第一个配置源呢
再次运用之前我们学到的那个小技巧来查看实例化ConfigurationPropertySourcesPropertySource的地方
<img src="https://static001.geekbang.org/resource/image/f4/5d/f43c15a2f491d88a0383023a42cebd5d.png" alt="">
可以看到ConfigurationPropertySourcesPropertySource类是由ConfigurationPropertySources的attach方法实例化的。查阅源码可以发现这个方法的确从环境中获得了原始的MutablePropertySources把自己加入成为一个元素
```
public final class ConfigurationPropertySources {
public static void attach(Environment environment) {
MutablePropertySources sources = ((ConfigurableEnvironment) environment).getPropertySources();
PropertySource&lt;?&gt; attached = sources.get(ATTACHED_PROPERTY_SOURCE_NAME);
if (attached == null) {
sources.addFirst(new ConfigurationPropertySourcesPropertySource(ATTACHED_PROPERTY_SOURCE_NAME,
new SpringConfigurationPropertySources(sources)));
}
}
}
```
而这个attach方法是Spring应用程序启动时准备环境的时候调用的。在SpringApplication的run方法中调用了prepareEnvironment方法然后又调用了ConfigurationPropertySources.attach方法
```
public class SpringApplication {
public ConfigurableApplicationContext run(String... args) {
...
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
...
}
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
ApplicationArguments applicationArguments) {
...
ConfigurationPropertySources.attach(environment);
...
}
}
```
看到这里你是否彻底理清楚Spring劫持PropertySourcesPropertyResolver的实现方式以及配置源有优先级的原因了呢如果你想知道Spring各种预定义的配置源的优先级可以参考[官方文档](https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config)。
## 重点回顾
今天我用两个业务开发中的实际案例带你进一步学习了Spring的AOP和配置优先级这两大知识点。现在你应该也感受到Spring实现的复杂度了。
对于AOP切Feign的案例我们在实现功能时走了一些弯路。Spring Cloud会使用Spring Boot的特性根据当前引入包的情况做各种自动装配。如果我们要扩展Spring的组件那么只有清晰了解Spring自动装配的运作方式才能鉴别运行时对象在Spring容器中的情况不能想当然认为代码中能看到的所有Spring的类都是Bean。
对于配置优先级的案例分析配置源优先级时如果我们以为看到PropertySourcesPropertyResolver就看到了真相后续进行扩展开发时就可能会踩坑。我们一定要注意**分析Spring源码时你看到的表象不一定是实际运行时的情况还需要借助日志或调试工具来理清整个过程**。如果没有调试工具,你可以借助[第11讲](https://time.geekbang.org/column/article/216830)用到的Arthas来分析代码调用路径。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 除了我们这两讲用到execution、within、@within@annotation四个指示器外Spring AOP还支持this、target、args、@target@args。你能说说后面五种指示器的作用吗?
1. Spring的Environment中的PropertySources属性可以包含多个PropertySource越往前优先级越高。那我们能否利用这个特点实现配置文件中属性值的自动赋值呢比如我们可以定义%%MYSQL.URL%%、%%MYSQL.USERNAME%%和%%MYSQL.PASSWORD%%分别代表数据库连接字符串、用户名和密码。在配置数据源时我们只要设置其值为占位符框架就可以自动根据当前应用程序名application.name统一把占位符替换为真实的数据库信息。这样生产的数据库信息就不需要放在配置文件中了会更安全。
关于Spring Core、Spring Boot和Spring Cloud你还遇到过其他坑吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,415 @@
<audio id="audio" title="答疑篇:代码篇思考题集锦(一)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/a7/1affbd7373c56ab8fe904e2e7045a0a7.mp3"></audio>
你好,我是朱晔。
在回复《Java 业务开发常见错误100例》这门课留言的过程中我看到有些同学特别想看一看咱们这个课程所有思考题的答案。因此呢我特地将这个课程涉及的思考题进行了梳理把其中的67个问题的答案或者说解题思路详细地写了出来并整理成了一个“答疑篇”模块。
我把这些问题拆分为了6篇分别更新你可以根据自己的时间来学习以保证学习效果。你可以通过这些回答再来回顾下这些知识点以求温故而知新同时你也可以对照着我的回答对比下自己的解题思路看看有没有什么不一样的地方并留言给我。
今天是答疑篇的第一讲我们一起来分析下咱们这门课前6讲的课后思考题。这些题目涉及了并发工具、代码加锁、线程池、连接池、HTTP调用和Spring声明式事务的12道思考题。
接下来,我们就一一具体分析吧。
### [01 | 使用了并发工具类库,线程安全就高枕无忧了吗?](https://time.geekbang.org/column/article/209494)
**问题1**ThreadLocalRandom是Java 7引入的一个生成随机数的类。你觉得可以把它的实例设置到静态变量中在多线程情况下重用吗
答:不能。
[ThreadLocalRandom文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadLocalRandom.html)里有这么一条:
>
Usages of this class should typically be of the form: ThreadLocalRandom.current().nextX(…) (where X is Int, Long, etc). When all usages are of this form, it is never possible to accidently share a ThreadLocalRandom across multiple threads.
那为什么规定要ThreadLocalRandom.current().nextX(…)这样来使用呢?我来分析下原因吧。
current()的时候初始化一个初始化种子到线程每次nextseed再使用之前的种子生成新的种子
```
UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA);
```
如果你通过主线程调用一次current生成一个ThreadLocalRandom的实例保存起来那么其它线程来获取种子的时候必然取不到初始种子必须是每一个线程自己用的时候初始化一个种子到线程。你可以在nextSeed方法设置一个断点来测试
```
UNSAFE.getLong(Thread.currentThread(),SEED);
```
**问题2**ConcurrentHashMap还提供了putIfAbsent方法你能否通过查阅[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html)说说computeIfAbsent和putIfAbsent方法的区别
computeIfAbsent和putIfAbsent这两个方法都是判断值不存在的时候为Map进行赋值的原子方法它们的区别具体包括以下三个方面
1. 当Key存在的时候如果Value的获取比较昂贵的话putIfAbsent方法就会白白浪费时间在获取这个昂贵的Value上这个点特别注意而computeIfAbsent则会因为传入的是Lambda表达式而不是实际值不会有这个问题。
1. Key不存在的时候putIfAbsent会返回null这时候我们要小心空指针而computeIfAbsent会返回计算后的值不存在空指针的问题。
1. 当Key不存在的时候putIfAbsent允许put null进去而computeIfAbsent不能当然了此条针对HashMapConcurrentHashMap不允许put null value进去
我写了一段代码来证明这三点,你可以点击[这里](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/concurrenttool/ciavspia/CommonMistakesApplication.java)的GitHub链接查看。
### [02 | 代码加锁:不要让“锁”事成为烦心事](https://time.geekbang.org/column/article/209520)
**问题1**在这一讲开头的例子里我们为变量a、b都使用了volatile关键字进行修饰你知道volatile关键字的作用吗我之前遇到过这样一个坑我们开启了一个线程无限循环来跑一些任务有一个bool类型的变量来控制循环的退出默认为true代表执行一段时间后主线程将这个变量设置为了false。如果这个变量不是volatile修饰的子线程可以退出吗你能否解释其中的原因呢
不能退出。比如下面的代码3秒后另一个线程把b设置为false但是主线程无法退出
```
private static boolean b = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-&gt;{
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) { }
b =false;
}).start();
while (b) {
TimeUnit.MILLISECONDS.sleep(0);
}
System.out.println(&quot;done&quot;);
}
```
其实,这是可见性的问题。
虽然另一个线程把b设置为了false但是这个字段在CPU缓存中另一个线程主线程还是读不到最新的值。使用volatile关键字可以让数据刷新到主内存中去。准确来说让数据刷新到主内存中去是两件事情
1. 将当前处理器缓存行的数据,写回到系统内存;
1. 这个写回内存的操作会导致其他CPU里缓存了该内存地址的数据变为无效。
当然使用AtomicBoolean等关键字来修改变量b也行。但相比volatile来说AtomicBoolean等关键字除了确保可见性还提供了CAS方法具有更多的功能在本例的场景中用不到。
**问题2**关于代码加锁还有两个坑,一是加锁和释放没有配对的问题,二是分布式锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两个问题吗?
针对加解锁没有配对的问题我们可以用一些代码质量工具或代码扫描工具比如Sonar来帮助排查。这个问题在编码阶段就能发现。
针对分布式锁超时自动释放问题可以参考Redisson的RedissonLock的[锁续期机制](https://github.com/redisson/redisson/blob/e11c1e14ba50bc5938184fb96d9b72782e591df7/redisson/src/main/java/org/redisson/RedissonLock.java#L265)。锁续期是每次续一段时间比如30秒然后10秒执行一次续期。虽然是无限次续期但即使客户端崩溃了也没关系不会无限期占用锁因为崩溃后无法自动续期自然最终会超时。
### [03 | 线程池:业务代码最常用也最容易犯错的组件](https://time.geekbang.org/column/article/210337)
**问题1**在讲线程池的管理策略时我们提到,或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?
答:我们按照文中提到的两个思路来实现一下激进线程池:
1. 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们可以重写队列的 offer 方法,造成这个队列已满的假象;
1. 由于我们 Hack 了队列,在达到了最大线程后势必会触发拒绝策略,那么我们还需要实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列。
完整的实现代码以及相应的测试代码如下:
```
@GetMapping(&quot;better&quot;)
public int better() throws InterruptedException {
//这里开始是激进线程池的实现
BlockingQueue&lt;Runnable&gt; queue = new LinkedBlockingQueue&lt;Runnable&gt;(10) {
@Override
public boolean offer(Runnable e) {
//先返回false造成队列满的假象让线程池优先扩容
return false;
}
};
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, 5,
5, TimeUnit.SECONDS,
queue, new ThreadFactoryBuilder().setNameFormat(&quot;demo-threadpool-%d&quot;).get(), (r, executor) -&gt; {
try {
//等出现拒绝后再加入队列
//如果希望队列满了阻塞线程而不是抛出异常那么可以注释掉下面三行代码修改为executor.getQueue().put(r);
if (!executor.getQueue().offer(r, 0, TimeUnit.SECONDS)) {
throw new RejectedExecutionException(&quot;ThreadPool queue full, failed to offer &quot; + r.toString());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
//激进线程池实现结束
printStats(threadPool);
//每秒提交一个任务每个任务耗时10秒执行完成一共提交20个任务
//任务编号计数器
AtomicInteger atomicInteger = new AtomicInteger();
IntStream.rangeClosed(1, 20).forEach(i -&gt; {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
int id = atomicInteger.incrementAndGet();
try {
threadPool.submit(() -&gt; {
log.info(&quot;{} started&quot;, id);
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
}
log.info(&quot;{} finished&quot;, id);
});
} catch (Exception ex) {
log.error(&quot;error submitting task {}&quot;, id, ex);
atomicInteger.decrementAndGet();
}
});
TimeUnit.SECONDS.sleep(60);
return atomicInteger.intValue();
}
```
使用这个激进的线程池可以处理完这20个任务因为我们优先开启了更多线程来处理任务。
```
[10:57:16.092] [demo-threadpool-4] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:157 ] - 20 finished
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:22 ] - =========================
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:23 ] - Pool Size: 5
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:24 ] - Active Threads: 0
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:25 ] - Number of Tasks Completed: 20
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:26 ] - Number of Tasks in Queue: 0
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:28 ] - =========================
```
**问题2**在讲“务必确认清楚线程池本身是不是复用”时我们改进了ThreadPoolHelper使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池10核心线程50最大线程2秒回收的反复执行测试接口线程最终可以被回收吗会出现OOM问题吗
会因为创建过多线程导致OOM因为默认情况下核心线程不会回收并且ThreadPoolExecutor也回收不了。
我们可以看看它的源码工作线程Worker是内部类只要它活着换句话说就是线程在跑就会阻止ThreadPoolExecutor回收
```
public class ThreadPoolExecutor extends AbstractExecutorService {
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{
}
}
```
因此我们不能认为ThreadPoolExecutor没有引用就能回收。
### [04 | 连接池:别让连接池帮了倒忙](https://time.geekbang.org/column/article/211388)
**问题1**有了连接池之后获取连接是从连接池获取没有足够连接时连接池会创建连接。这时获取连接操作往往有两个超时时间一个是从连接池获取连接的最长等待时间通常叫作请求连接超时connectRequestTimeout或连接等待超时connectWaitTimeout一个是连接池新建TCP连接三次握手的连接超时通常叫作连接超时connectTimeout。针对JedisPool、Apache HttpClient和Hikari数据库连接池你知道如何设置这2个参数吗
假设我们希望设置连接超时5s、请求连接超时10s下面我来演示下如何配置Hikari、Jedis和HttpClient的两个超时参数。
针对Hikari设置两个超时时间的方式是修改数据库连接字符串中的connectTimeout属性和配置文件中的hikari配置的connection-timeout
```
spring.datasource.hikari.connection-timeout=10000
spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?connectTimeout=5000&amp;characterEncoding=UTF-8&amp;useSSL=false&amp;rewriteBatchedStatements=true
```
针对Jedis是设置JedisPoolConfig的MaxWaitMillis属性和设置创建JedisPool时的timeout属性
```
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxWaitMillis(10000);
try (JedisPool jedisPool = new JedisPool(config, &quot;127.0.0.1&quot;, 6379, 5000);
Jedis jedis = jedisPool.getResource()) {
return jedis.set(&quot;test&quot;, &quot;test&quot;);
}
```
针对HttpClient是设置RequestConfig的ConnectionRequestTimeout和ConnectTimeout属性
```
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(5000)
.setConnectionRequestTimeout(10000)
.build();
HttpGet httpGet = new HttpGet(&quot;http://127.0.0.1:45678/twotimeoutconfig/test&quot;);
httpGet.setConfig(requestConfig);
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
return EntityUtils.toString(response.getEntity());
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
```
也可以直接参考我放在[GitHub](https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/connectionpool/twotimeoutconfig)上的源码。
**问题2**对于带有连接池的SDK的使用姿势最主要的是鉴别其内部是否实现了连接池如果实现了连接池要尽量复用Client。对于NoSQL中的MongoDB来说使用MongoDB Java驱动时MongoClient类应该是每次都创建还是复用呢你能否在[官方文档](https://mongodb.github.io/mongo-java-driver/3.12/)中找到答案呢?
答:官方文档里有这么一段话:
>
<p>Typically you only create one MongoClient instance for a given MongoDB deployment (e.g. standalone, replica set, or a sharded cluster) and use it across your application. However, if you do create multiple instances:<br>
All resource usage limits (e.g. max connections, etc.) apply per MongoClient instance.<br>
To dispose of an instance, call MongoClient.close() to clean up resources.</p>
MongoClient类应该尽可能复用一个MongoDB部署只使用一个MongoClient不过复用不等于在任何情况下就只用一个。正如文档里所说每一个MongoClient示例有自己独立的资源限制。
### [05 | HTTP调用你考虑到超时、重试、并发了吗](https://time.geekbang.org/column/article/213273)
**问题1**在“配置连接超时和读取超时参数的学问”这一节中我们强调了要注意连接超时和读取超时参数的配置大多数的HTTP客户端也都有这两个参数。有读就有写但为什么我们很少看到“写入超时”的概念呢
其实写入操作只是将数据写入TCP的发送缓冲区已经发送到网络的数据依然需要暂存在发送缓冲区中只有收到对方的ack后操作系统内核才从缓冲区中清除这一部分数据为后续发送数据腾出空间。
如果接收端从socket读取数据的速度太慢可能会导致发送端发送缓冲区满导致写入操作阻塞产生写入超时。但是因为有滑动窗口的控制通常不太容易发生发送缓冲区满导致写入超时的情况。相反读取超时包含了服务端处理数据执行业务逻辑的时间所以读取超时是比较容易发生的。
这也就是为什么我们一般都会比较重视读取超时而不是写入超时的原因了。
**问题2**除了Ribbon的AutoRetriesNextServer重试机制Nginx也有类似的重试功能。你了解Nginx相关的配置吗
关于Nginx的重试功能你可以参考[这里](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_next_upstream)了解下Nginx的proxy_next_upstream配置。
proxy_next_upstream用于指定在什么情况下Nginx会将请求转移到其他服务器上。其默认值是proxy_next_upstream error timeout即发生网络错误以及超时才会重试其他服务器。也就是说默认情况下服务返回500状态码是不会重试的。
如果我们想在请求返回500状态码时也进行重试可以配置
```
proxy_next_upstream error timeout http_500;
```
需要注意的是proxy_next_upstream配置中有一个选项non_idempotent一定要小心开启。通常情况下如果请求使用非等幂方法POST、PATCH请求失败后不会再到其他服务器进行重试。但是加上non_idempotent这个选项后即使是非幂等请求类型例如POST请求发生错误后也会重试。
### [06 | 20%的业务代码的Spring声明式事务可能都没处理正确](https://time.geekbang.org/column/article/213295)
**问题1**考虑到Demo的简洁这一讲中所有数据访问使用的都是Spring Data JPA。国内大多数互联网业务项目是使用MyBatis进行数据访问的使用MyBatis配合Spring的声明式事务也同样需要注意这一讲中提到的这些点。你可以尝试把今天的Demo改为MyBatis做数据访问实现看看日志中是否可以体现出这些坑
使用mybatis-spring-boot-starter无需做任何配置即可使MyBatis整合Spring的声明式事务。在GitHub上的课程[源码](https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/transaction/nested)中我更新了一个使用MyBatis配套嵌套事务的例子实现的效果是主方法出现异常子方法的嵌套事务也会回滚。
我来和你解释下这个例子中的核心代码:
```
@Transactional
public void createUser(String name) {
createMainUser(name);
try {
subUserService.createSubUser(name);
} catch (Exception ex) {
log.error(&quot;create sub user error:{}&quot;, ex.getMessage());
}
//如果createSubUser是NESTED模式这里抛出异常会导致嵌套事务无法“提交”
throw new RuntimeException(&quot;create main user error&quot;);
}
```
子方法使用了NESTED事务传播模式
```
@Transactional(propagation = Propagation.NESTED)
public void createSubUser(String name) {
userDataMapper.insert(name, &quot;sub&quot;);
}
```
执行日志如下图所示:
<img src="https://static001.geekbang.org/resource/image/cd/0e/cda8d69f99c0063046a085a39d520c0e.png" alt="">
每个NESTED事务执行前会将当前操作保存下来叫做savepoint保存点。NESTED事务在外部事务提交以后自己才会提交如果当前NESTED事务执行失败则回滚到之前的保存点。
**问题2**在讲“小心 Spring 的事务可能没有生效”时我们提到如果要针对private方法启用事务动态代理方式的AOP不可行需要使用静态织入方式的AOP也就是在编译期间织入事务增强代码可以配置Spring框架使用AspectJ来实现AOP。你能否参阅Spring的文档“[Using @Transactional with AspectJ](https://docs.spring.io/spring/docs/current/spring-framework-reference/data-access.html#transaction-declarative-aspectj)”试试呢注意AspectJ配合lombok使用还可能会踩一些坑。
我们需要加入aspectj的依赖和配置aspectj-maven-plugin插件并且需要设置Spring开启AspectJ事务管理模式。具体的实现方式包括如下4步。
第一步引入spring-aspects依赖
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework&lt;/groupId&gt;
&lt;artifactId&gt;spring-aspects&lt;/artifactId&gt;
&lt;/dependency&gt;
```
第二步加入lombok和aspectj插件
```
&lt;plugin&gt;
&lt;groupId&gt;org.projectlombok&lt;/groupId&gt;
&lt;artifactId&gt;lombok-maven-plugin&lt;/artifactId&gt;
&lt;version&gt;1.18.0.0&lt;/version&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;phase&gt;generate-sources&lt;/phase&gt;
&lt;goals&gt;
&lt;goal&gt;delombok&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;configuration&gt;
&lt;addOutputDirectory&gt;false&lt;/addOutputDirectory&gt;
&lt;sourceDirectory&gt;src/main/java&lt;/sourceDirectory&gt;
&lt;/configuration&gt;
&lt;/plugin&gt;
&lt;plugin&gt;
&lt;groupId&gt;org.codehaus.mojo&lt;/groupId&gt;
&lt;artifactId&gt;aspectj-maven-plugin&lt;/artifactId&gt;
&lt;version&gt;1.10&lt;/version&gt;
&lt;configuration&gt;
&lt;complianceLevel&gt;1.8&lt;/complianceLevel&gt;
&lt;source&gt;1.8&lt;/source&gt;
&lt;aspectLibraries&gt;
&lt;aspectLibrary&gt;
&lt;groupId&gt;org.springframework&lt;/groupId&gt;
&lt;artifactId&gt;spring-aspects&lt;/artifactId&gt;
&lt;/aspectLibrary&gt;
&lt;/aspectLibraries&gt;
&lt;/configuration&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;goals&gt;
&lt;goal&gt;compile&lt;/goal&gt;
&lt;goal&gt;test-compile&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;/plugin&gt;
```
使用delombok插件的目的是把代码中的Lombok注解先编译为代码这样AspectJ编译不会有问题同时需要设置<build>中的sourceDirectory为delombok目录</build>
```
&lt;sourceDirectory&gt;${project.build.directory}/generated-sources/delombok&lt;/sourceDirectory&gt;
```
第三步,设置@EnableTransactionManagement注解开启事务管理走AspectJ模式
```
@SpringBootApplication
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class CommonMistakesApplication {
```
第四步使用Maven编译项目编译后查看createUserPrivate方法的源码可以发现AspectJ帮我们做编译时织入Compile Time Weaving
<img src="https://static001.geekbang.org/resource/image/11/1d/11da9146b324e47fcd96631d47db961d.png" alt="">
运行程序观察日志可以发现createUserPrivate私有方法同样应用了事务出异常后事务回滚
```
[14:21:39.155] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.transactionproxyfailed.UserService.createUserPrivate]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[14:21:39.155] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:393 ] - Opened new EntityManager [SessionImpl(1087443072&lt;open&gt;)] for JPA transaction
[14:21:39.158] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:421 ] - Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@4e16e6ea]
[14:21:39.159] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:356 ] - Found thread-bound EntityManager [SessionImpl(1087443072&lt;open&gt;)] for JPA transaction
[14:21:39.159] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:471 ] - Participating in existing transaction
[14:21:39.173] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:834 ] - Initiating transaction rollback
[14:21:39.173] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1087443072&lt;open&gt;)]
[14:21:39.176] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:620 ] - Closing JPA EntityManager [SessionImpl(1087443072&lt;open&gt;)] after transaction
[14:21:39.176] [http-nio-45678-exec-2] [ERROR] [o.g.t.c.t.t.UserService:28 ] - create user failed because invalid username!
[14:21:39.177] [http-nio-45678-exec-2] [DEBUG] [o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler:305 ] - Creating new EntityManager for shared EntityManager invocation
```
以上就是咱们这门课前6讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,498 @@
<audio id="audio" title="答疑篇:代码篇思考题集锦(三)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/92/47d7c5a60208e19a8d718e56c11d7292.mp3"></audio>
你好,我是朱晔。
今天我们继续一起分析这门课第13~20讲的课后思考题。这些题目涉及了日志、文件IO、序列化、Java 8日期时间类、OOM、Java高级特性反射、注解和泛型和Spring框架的16道问题。
接下来,我们就一一具体分析吧。
### [13 | 日志:日志记录真没你想象的那么简单](https://time.geekbang.org/column/article/220307)
**问题1**在讲“为什么我的日志会重复记录”的案例时我们把INFO级别的日志存放到_info.log中把WARN和ERROR级别的日志存放到_error.log中。如果现在要把INFO和WARN级别的日志存放到_info.log中把ERROR日志存放到_error.log中应该如何配置Logback呢
要实现这个配置有两种方式分别是直接使用EvaluatorFilter和自定义一个Filter。我们分别看一下。
第一种方式是直接使用logback自带的EvaluatorFilter
```
&lt;filter class=&quot;ch.qos.logback.core.filter.EvaluatorFilter&quot;&gt;
&lt;evaluator class=&quot;ch.qos.logback.classic.boolex.GEventEvaluator&quot;&gt;
&lt;expression&gt;
e.level.toInt() == WARN.toInt() || e.level.toInt() == INFO.toInt()
&lt;/expression&gt;
&lt;/evaluator&gt;
&lt;OnMismatch&gt;DENY&lt;/OnMismatch&gt;
&lt;OnMatch&gt;NEUTRAL&lt;/OnMatch&gt;
&lt;/filter&gt;
```
第二种方式是自定义一个Filter实现解析配置中的“|”字符分割的多个Level
```
public class MultipleLevelsFilter extends Filter&lt;ILoggingEvent&gt; {
@Getter
@Setter
private String levels;
private List&lt;Integer&gt; levelList;
@Override
public FilterReply decide(ILoggingEvent event) {
if (levelList == null &amp;&amp; !StringUtils.isEmpty(levels)) {
//把由|分割的多个Level转换为List&lt;Integer&gt;
levelList = Arrays.asList(levels.split(&quot;\\|&quot;)).stream()
.map(item -&gt; Level.valueOf(item))
.map(level -&gt; level.toInt())
.collect(Collectors.toList());
}
//如果levelList包含当前日志的级别则接收否则拒绝
if (levelList.contains(event.getLevel().toInt()))
return FilterReply.ACCEPT;
else
return FilterReply.DENY;
}
}
```
然后在配置文件中使用这个MultipleLevelsFilter就可以了完整的配置代码参考[这里](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/logging/duplicate/multiplelevelsfilter.xml)
```
&lt;filter class=&quot;org.geekbang.time.commonmistakes.logging.duplicate.MultipleLevelsFilter&quot;&gt;
&lt;levels&gt;INFO|WARN&lt;/levels&gt;
&lt;/filter&gt;
```
**问题2**生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理,以避免单个文件太大,同时保留一定天数的历史日志,你知道如何配置吗?可以在[官方文档](http://logback.qos.ch/manual/appenders.html#RollingFileAppender)找到答案。
参考配置如下使用SizeAndTimeBasedRollingPolicy来实现按照文件大小和历史文件保留天数进行文件分割和归档
```
&lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy&quot;&gt;
&lt;!--日志文件保留天数--&gt;
&lt;MaxHistory&gt;30&lt;/MaxHistory&gt;
&lt;!--日志文件最大的大小--&gt;
&lt;MaxFileSize&gt;100MB&lt;/MaxFileSize&gt;
&lt;!--日志整体最大
可选的totalSizeCap属性控制所有归档文件的总大小。当超过总大小上限时将异步删除最旧的存档。
totalSizeCap属性也需要设置maxHistory属性。此外“最大历史”限制总是首先应用“总大小上限”限制其次应用。
--&gt;
&lt;totalSizeCap&gt;10GB&lt;/totalSizeCap&gt;
&lt;/rollingPolicy&gt;
```
### [14 | 文件IO实现高效正确的文件读写并非易事](https://time.geekbang.org/column/article/223051)
**问题1**Files.lines方法进行流式处理需要使用try-with-resources进行资源释放。那么使用Files类中其他返回Stream包装对象的方法进行流式处理比如newDirectoryStream方法返回DirectoryStream<path>list、walk和find方法返回Stream<path>,也同样有资源释放问题吗?</path></path>
使用Files类中其他返回Stream包装对象的方法进行流式处理也同样会有资源释放问题。
因为这些接口都需要使用try-with-resources模式来释放。正如文中所说如果不显式释放那么可能因为底层资源没有及时关闭造成资源泄露。
**问题2**Java的File类和Files类提供的文件复制、重命名、删除等操作是原子性的吗
Java的File和Files类的文件复制、重命名、删除等操作都不是原子性的。原因是文件类操作基本都是调用操作系统本身的API一般来说这些文件API并不像数据库有事务机制也很难办到即使有也很可能有平台差异性。
比如File.renameTo方法的文档中提到
>
Many aspects of the behavior of this method are inherently platform-dependent: The rename operation might not be able to move a file from one filesystem to another, it might not be atomic, and it might not succeed if a file with the destination abstract pathname already exists. The return value should always be checked to make sure that the rename operation was successful.
又比如Files.copy方法的文档中提到
>
Copying a file is not an atomic operation. If an IOException is thrown, then it is possible that the target file is incomplete or some of its file attributes have not been copied from the source file. When the REPLACE_EXISTING option is specified and the target file exists, then the target file is replaced. The check for the existence of the file and the creation of the new file may not be atomic with respect to other file system activities.
### [15 | 序列化:一来一回你还是原来的你吗?](https://time.geekbang.org/column/article/223111)
**问题1**在讨论Redis序列化方式的时候我们自定义了RedisTemplate让Key使用String序列化、让Value使用JSON序列化从而使Redis获得的Value可以直接转换为需要的对象类型。那么使用RedisTemplate&lt;String, Long&gt;能否存取Value是Long的数据呢这其中有什么坑吗
使用RedisTemplate&lt;String, Long&gt;不一定能存取Value是Long的数据。在Integer区间内返回的是Integer超过这个区间返回Long。测试代码如下
```
@GetMapping(&quot;wrong2&quot;)
public void wrong2() {
String key = &quot;testCounter&quot;;
//测试一下设置在Integer范围内的值
countRedisTemplate.opsForValue().set(key, 1L);
log.info(&quot;{} {}&quot;, countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
Long l1 = getLongFromRedis(key);
//测试一下设置超过Integer范围的值
countRedisTemplate.opsForValue().set(key, Integer.MAX_VALUE + 1L);
log.info(&quot;{} {}&quot;, countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
//使用getLongFromRedis转换后的值必定是Long
Long l2 = getLongFromRedis(key);
log.info(&quot;{} {}&quot;, l1, l2);
}
private Long getLongFromRedis(String key) {
Object o = countRedisTemplate.opsForValue().get(key);
if (o instanceof Integer) {
return ((Integer) o).longValue();
}
if (o instanceof Long) {
return (Long) o;
}
return null;
}
```
会得到如下输出:
```
1 false
2147483648 true
1 2147483648
```
可以看到值设置1的时候类型不是Long设置2147483648的时候是Long。也就是使用RedisTemplate&lt;String, Long&gt;不一定就代表获取的到的Value是Long。
所以这边我写了一个getLongFromRedis方法来做转换避免出错判断当值是Integer的时候转换为Long。
**问题2**你可以看一下Jackson2ObjectMapperBuilder类源码的实现注意configure方法分析一下其除了关闭FAIL_ON_UNKNOWN_PROPERTIES外还做了什么吗
除了关闭FAIL_ON_UNKNOWN_PROPERTIES外Jackson2ObjectMapperBuilder类源码还主要做了以下两方面的事儿。
第一设置Jackson的一些默认值比如
- MapperFeature.DEFAULT_VIEW_INCLUSION设置为禁用
- DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES设置为禁用。
第二自动注册classpath中存在的一些jackson模块比如
- jackson-datatype-jdk8支持JDK8的一些类型比如Optional
- jackson-datatype-jsr310 支持JDK8的日期时间一些类型。
- jackson-datatype-joda支持Joda-Time类型。
- jackson-module-kotlin支持Kotlin。
### [16 | 用好Java 8的日期时间类少踩一些“老三样”的坑](https://time.geekbang.org/column/article/224240)
**问题1**在这一讲中我多次强调了Date是一个时间戳是UTC时间、没有时区概念。那为什么调用其toString方法会输出类似CST之类的时区字样呢
关于这个问题参考toString中的相关源码你可以看到会获取当前时区取不到则显示GMT进行格式化
```
public String toString() {
BaseCalendar.Date date = normalize();
...
TimeZone zi = date.getZone();
if (zi != null) {
sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)); // zzz
} else {
sb.append(&quot;GMT&quot;);
}
sb.append(' ').append(date.getYear()); // yyyy
return sb.toString();
}
private final BaseCalendar.Date normalize() {
if (cdate == null) {
BaseCalendar cal = getCalendarSystem(fastTime);
cdate = (BaseCalendar.Date) cal.getCalendarDate(fastTime,
TimeZone.getDefaultRef());
return cdate;
}
// Normalize cdate with the TimeZone in cdate first. This is
// required for the compatible behavior.
if (!cdate.isNormalized()) {
cdate = normalize(cdate);
}
// If the default TimeZone has changed, then recalculate the
// fields with the new TimeZone.
TimeZone tz = TimeZone.getDefaultRef();
if (tz != cdate.getZone()) {
cdate.setZone(tz);
CalendarSystem cal = getCalendarSystem(cdate);
cal.getCalendarDate(fastTime, cdate);
}
return cdate;
}
```
其实说白了这里显示的时区仅仅用于呈现并不代表Date类内置了时区信息。
**问题2**日期时间数据始终要保存到数据库中MySQL中有两种数据类型datetime和timestamp可以用来保存日期时间。你能说说它们的区别吗它们是否包含时区信息呢
datetime和timestamp的区别主要体现在占用空间、表示的时间范围和时区三个方面。
- 占用空间datetime占用8字节timestamp占用4字节。
- 表示的时间范围datetime表示的范围是从“1000-01-01 00:00:00.000000”到“9999-12-31 23:59:59.999999”timestamp表示的范围是从“1970-01-01 00:00:01.000000”到“2038-01-19 03:14:07.999999”。
- 时区timestamp保存的时候根据当前时区转换为UTC查询的时候再根据当前时区从UTC转回来而datetime就是一个死的字符串时间仅仅对MySQL本身而言表示。
需要注意的是我们说datetime不包含时区是固定的时间表示仅仅是指MySQL本身。使用timestamp需要考虑Java进程的时区和MySQL连接的时区。而使用datetime类型则只需要考虑Java进程的时区因为MySQL datetime没有时区信息了JDBC时间戳转换成MySQL datetime会根据MySQL的serverTimezone做一次转换
如果你的项目有国际化需求,我推荐使用时间戳,并且要确保你的应用服务器和数据库服务器设置了正确的匹配当地时区的时区配置。
其实,即便你的项目没有国际化需求,至少是应用服务器和数据库服务器设置一致的时区,也是需要的。
### [17 | 别以为“自动挡”就不可能出现OOM](https://time.geekbang.org/column/article/224784)
**问题1**Spring的ConcurrentReferenceHashMap针对Key和Value支持软引用和弱引用两种方式。你觉得哪种方式更适合做缓存呢
答:软引用和弱引用的区别在于:若一个对象是弱引用可达,无论当前内存是否充足它都会被回收,而软引用可达的对象在内存不充足时才会被回收。因此,软引用要比弱引用“强”一些。
那么,使用弱引用作为缓存就会让缓存的生命周期过短,所以软引用更适合作为缓存。
**问题2**当我们需要动态执行一些表达式时可以使用Groovy动态语言实现new出一个GroovyShell类然后调用evaluate方法动态执行脚本。这种方式的问题是会重复产生大量的类增加Metaspace区的GC负担有可能会引起OOM。你知道如何避免这个问题吗
调用evaluate方法动态执行脚本会产生大量的类要避免可能因此导致的OOM问题我们可以把脚本包装为一个函数先调用parse函数来得到Script对象然后缓存起来以后直接使用invokeMethod方法调用这个函数即可
```
private Object rightGroovy(String script, String method, Object... args) {
Script scriptObject;
if (SCRIPT_CACHE.containsKey(script)) {
//如果脚本已经生成过Script则直接使用
scriptObject = SCRIPT_CACHE.get(script);
} else {
//否则把脚本解析为Script
scriptObject = shell.parse(script);
SCRIPT_CACHE.put(script, scriptObject);
}
return scriptObject.invokeMethod(method, args);
}
```
我在源码中提供了一个[测试程序](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/oom/groovyoom/GroovyOOMController.java),你可以直接去看一下。
### [18 | 当反射、注解和泛型遇到OOP时会有哪些坑](https://time.geekbang.org/column/article/225596)
**问题1**泛型类型擦除后会生成一个bridge方法这个方法同时又是synthetic方法。除了泛型类型擦除你知道还有什么情况编译器会生成synthetic方法吗
Synthetic方法是编译器自动生成的方法在源码中不出现。除了文中提到的泛型类型擦除外Synthetic方法还可能出现的一个比较常见的场景是内部类和顶层类需要相互访问对方的private字段或方法的时候。
编译后的内部类和普通类没有区别遵循private字段或方法对外部类不可见的原则但语法上内部类和顶层类的私有字段需要可以相互访问。为了解决这个矛盾编译器就只能生成桥接方法也就是Synthetic方法来把private成员转换为package级别的访问限制。
比如如下代码InnerClassApplication类的test方法需要访问内部类MyInnerClass类的私有字段name而内部类MyInnerClass类的test方法需要访问外部类InnerClassApplication类的私有字段gender。
```
public class InnerClassApplication {
private String gender = &quot;male&quot;;
public static void main(String[] args) throws Exception {
InnerClassApplication application = new InnerClassApplication();
application.test();
}
private void test(){
MyInnerClass myInnerClass = new MyInnerClass();
System.out.println(myInnerClass.name);
myInnerClass.test();
}
class MyInnerClass {
private String name = &quot;zhuye&quot;;
void test(){
System.out.println(gender);
}
}
}
```
编译器会为InnerClassApplication和MyInnerClass都生成桥接方法。
如下图所示InnerClassApplication的test方法其实调用的是内部类的access$000静态方法
<img src="https://static001.geekbang.org/resource/image/93/66/93a0fd1feb705be9fd63c3b963943c66.png" alt="">
这个access$000方法是Synthetic方法
<img src="https://static001.geekbang.org/resource/image/2a/f0/2aa967cfbd7832d0893605c4249363f0.png" alt="">
而Synthetic方法的实现转接调用了内部类的name字段
<img src="https://static001.geekbang.org/resource/image/06/3d/064809b7fba7dc34f5c955a1a7dbf33d.png" alt="">
反过来内部类的test方法也是通过外部类InnerClassApplication类的桥接方法access$100调用到其私有字段
<img src="https://static001.geekbang.org/resource/image/eb/9e/ebefeeda2de626ca8cbdf5388763669e.png" alt="">
**问题2**关于注解继承问题你觉得Spring的常用注解@Service@Controller是否支持继承呢
Spring的常用注解@Service@Controller不支持继承。这些注解只支持放到具体的非接口非抽象顶层类上来让它们成为Bean如果支持继承会非常不灵活而且容易出错。
### [19 | Spring框架IoC和AOP是扩展的核心](https://time.geekbang.org/column/article/227917)
**问题1**除了通过@Autowired注入Bean外,还可以使用@Inject或@Resource来注入Bean。你知道这三种方式的区别是什么吗?
答:我们先说一下使用@Autowired@Inject和@Resource这三种注解注入Bean的方式
- @Autowired是Spring的注解优先按照类型注入。当无法确定具体注入类型的时候可以通过@Qualifier注解指定Bean名称
- @Inject是JSR330规范的实现也是根据类型进行自动装配的这一点和@Autowired类似。如果需要按名称进行装配,则需要配合使用@Named@Autowired和@Inject的区别在于前者可以使用required=false允许注入null后者允许注入一个Provider实现延迟注入。
- @ResourceJSR250规范的实现如果不指定name优先根据名称进行匹配然后才是类型如果指定name则仅根据名称匹配。
**问题2**当Bean产生循环依赖时比如BeanA的构造方法依赖BeanB作为成员需要注入BeanB也依赖BeanA你觉得会出现什么问题呢又有哪些解决方式呢
Bean产生循环依赖主要包括两种情况一种是注入属性或字段涉及循环依赖另一种是构造方法注入涉及循环依赖。接下来我分别和你讲一讲。
第一种注入属性或字段涉及循环依赖比如TestA和TestB相互依赖
```
@Component
public class TestA {
@Autowired
@Getter
private TestB testB;
}
@Component
public class TestB {
@Autowired
@Getter
private TestA testA;
}
```
针对这个问题Spring内部通过三个Map的方式解决了这个问题不会出错。基本原理是因为循环依赖所以实例的初始化无法一次到位需要分步进行
1. 创建A仅仅实例化不注入依赖
1. 创建B仅仅实例化不注入依赖
1. 为B注入A此时B已健全
1. 为A注入B此时A也健全
网上有很多相关的分析,我找了[一篇比较详细的](https://cloud.tencent.com/developer/article/1497692),可供你参考。
第二种构造方法注入涉及循环依赖。遇到这种情况的话程序无法启动比如TestC和TestD的相互依赖
```
@Component
public class TestC {
@Getter
private TestD testD;
@Autowired
public TestC(TestD testD) {
this.testD = testD;
}
}
@Component
public class TestD {
@Getter
private TestC testC;
@Autowired
public TestD(TestC testC) {
this.testC = testC;
}
}
```
这种循环依赖的主要解决方式有2种
- 改为属性或字段注入;
- 使用@Lazy延迟注入。比如如下代码:
```
@Component
public class TestC {
@Getter
private TestD testD;
@Autowired
public TestC(@Lazy TestD testD) {
this.testD = testD;
}
}
```
其实,这种@Lazy方式注入的就不是实际的类型了,而是代理类,获取的时候通过代理去拿值(实例化)。所以,它可以解决循环依赖无法实例化的问题。
### [20 | Spring框架框架帮我们做了很多工作也带来了复杂度](https://time.geekbang.org/column/article/227918)
**问题1**除了Spring框架这两讲涉及的execution、within、@within@annotation 四个指示器外Spring AOP 还支持 this、target、args、@target@args。你能说说后面五种指示器的作用吗?
答:关于这些指示器的作用,你可以参考[官方文档](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop-pointcuts-designators),文档里已经写的很清晰。
总结一下,按照使用场景,建议使用下面这些指示器:
- 针对方法签名使用execution
- 针对类型匹配使用within匹配类型、this匹配代理类实例、target匹配代理背后的目标类实例、args匹配参数
- 针对注解匹配,使用@annotation(使用指定注解标注的方法)、@target(使用指定注解标注的类)、@args(使用指定注解标注的类作为某个方法的参数)。
你可能会问,@within怎么没有呢
其实对于Spring默认的基于动态代理或CGLIB的AOP因为切点只能是方法使用@within和@target指示器并无区别但需要注意如果切换到AspectJ那么使用@within和@target这两个指示器的行为就会有所区别了@within会切入更多的成员的访问(比如静态构造方法、字段访问),一般而言使用@target指示器即可
**问题2**Spring 的 Environment 中的 PropertySources 属性可以包含多个 PropertySource越往前优先级越高。那我们能否利用这个特点实现配置文件中属性值的自动赋值呢比如我们可以定义 %%MYSQL.URL%%、%%MYSQL.USERNAME%% 和 %%MYSQL.PASSWORD%%,分别代表数据库连接字符串、用户名和密码。在配置数据源时,我们只要设置其值为占位符,框架就可以自动根据当前应用程序名 application.name统一把占位符替换为真实的数据库信息。这样生产的数据库信息就不需要放在配置文件中了会更安全。
我们利用PropertySource具有优先级的特点实现配置文件中属性值的自动赋值。主要逻辑是遍历现在的属性值找出能匹配到占位符的属性并把这些属性的值替换为实际的数据库信息然后再把这些替换后的属性值构成新的PropertiesPropertySource加入PropertySources的第一个。这样我们这个PropertiesPropertySource中的值就可以生效了。
主要源码如下:
```
public static void main(String[] args) {
Utils.loadPropertySource(CommonMistakesApplication.class, &quot;db.properties&quot;);
new SpringApplicationBuilder()
.sources(CommonMistakesApplication.class)
.initializers(context -&gt; initDbUrl(context.getEnvironment()))
.run(args);
}
private static final String MYSQL_URL_PLACEHOLDER = &quot;%%MYSQL.URL%%&quot;;
private static final String MYSQL_USERNAME_PLACEHOLDER = &quot;%%MYSQL.USERNAME%%&quot;;
private static final String MYSQL_PASSWORD_PLACEHOLDER = &quot;%%MYSQL.PASSWORD%%&quot;;
private static void initDbUrl(ConfigurableEnvironment env) {
String dataSourceUrl = env.getProperty(&quot;spring.datasource.url&quot;);
String username = env.getProperty(&quot;spring.datasource.username&quot;);
String password = env.getProperty(&quot;spring.datasource.password&quot;);
if (dataSourceUrl != null &amp;&amp; !dataSourceUrl.contains(MYSQL_URL_PLACEHOLDER))
throw new IllegalArgumentException(&quot;请使用占位符&quot; + MYSQL_URL_PLACEHOLDER + &quot;来替换数据库URL配置&quot;);
if (username != null &amp;&amp; !username.contains(MYSQL_USERNAME_PLACEHOLDER))
throw new IllegalArgumentException(&quot;请使用占位符&quot; + MYSQL_USERNAME_PLACEHOLDER + &quot;来替换数据库账号配置!&quot;);
if (password != null &amp;&amp; !password.contains(MYSQL_PASSWORD_PLACEHOLDER))
throw new IllegalArgumentException(&quot;请使用占位符&quot; + MYSQL_PASSWORD_PLACEHOLDER + &quot;来替换数据库密码配置!&quot;);
//这里我把值写死了,实际应用中可以从外部服务来获取
Map&lt;String, String&gt; property = new HashMap&lt;&gt;();
property.put(MYSQL_URL_PLACEHOLDER, &quot;jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&amp;useSSL=false&quot;);
property.put(MYSQL_USERNAME_PLACEHOLDER, &quot;root&quot;);
property.put(MYSQL_PASSWORD_PLACEHOLDER, &quot;kIo9u7Oi0eg&quot;);
//保存修改后的配置属性
Properties modifiedProps = new Properties();
//遍历现在的属性值,找出能匹配到占位符的属性,并把这些属性的值替换为实际的数据库信息
StreamSupport.stream(env.getPropertySources().spliterator(), false)
.filter(ps -&gt; ps instanceof EnumerablePropertySource)
.map(ps -&gt; ((EnumerablePropertySource) ps).getPropertyNames())
.flatMap(Arrays::stream)
.forEach(propKey -&gt; {
String propValue = env.getProperty(propKey);
property.entrySet().forEach(item -&gt; {
//如果原先配置的属性值包含我们定义的占位符
if (propValue.contains(item.getKey())) {
//那么就把实际的配置信息加入modifiedProps
modifiedProps.put(propKey, propValue.replaceAll(item.getKey(), item.getValue()));
}
});
});
if (!modifiedProps.isEmpty()) {
log.info(&quot;modifiedProps: {}&quot;, modifiedProps);
env.getPropertySources().addFirst(new PropertiesPropertySource(&quot;mysql&quot;, modifiedProps));
}
}
```
我在GitHub上第20讲对应的源码中更新了我的实现你可以点击[这里](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/springpart2/custompropertysource/CommonMistakesApplication.java)查看。有一些同学会问这么做的意义到底在于什么为何不直接使用类似Apollo这样的配置框架呢
其实我们的目的就是不希望让开发人员手动配置数据库信息希望程序启动的时候自动替换占位符实现自动配置从CMDB直接拿着应用程序ID来换取对应的数据库信息。你可能会问了一个应用程序ID对应多个数据库怎么办其实一般对于微服务系统来说一个应用就应该对应一个数据库。这样一来除了程序其他人都不会接触到生产的数据库信息会更安全。
以上就是咱们这门课的第13~20讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,316 @@
<audio id="audio" title="答疑篇:代码篇思考题集锦(二)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/f8/20ff92d160e92fb43de58a4d197d00f8.mp3"></audio>
你好,我是朱晔。
今天我们继续一起分析这门课第7~12讲的课后思考题。这些题目涉及了数据库索引、判等问题、数值计算、集合类、空值处理和异常处理的12道问题。
接下来,我们就一一具体分析吧。
### [07 | 数据库索引:索引并不是万能药](https://time.geekbang.org/column/article/213342)
**问题1**在介绍二级索引代价时我们通过EXPLAIN命令看到了索引覆盖和回表的两种情况。你能用optimizer trace来分析一下这两种情况的成本差异吗
如下代码所示打开optimizer_trace后再执行SQL就可以查询information_schema.OPTIMIZER_TRACE表查看执行计划了最后可以关闭optimizer_trace功能
```
SET optimizer_trace=&quot;enabled=on&quot;;
SELECT * FROM person WHERE NAME &gt;'name84059' AND create_time&gt;'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace=&quot;enabled=off&quot;;
```
假设我们为表person的NAME和SCORE列建了联合索引那么下面第二条语句应该可以走索引覆盖而第一条语句需要回表
```
explain select * from person where NAME='name1';
explain select NAME,SCORE from person where NAME='name1';
```
通过观察OPTIMIZER_TRACE的输出可以看到索引覆盖index_only=true的成本是1.21而回表查询index_only=false的是2.21也就是索引覆盖节省了回表的成本1。
索引覆盖:
```
analyzing_range_alternatives&quot;: {
&quot;range_scan_alternatives&quot;: [
{
&quot;index&quot;: &quot;name_score&quot;,
&quot;ranges&quot;: [
&quot;name1 &lt;= name &lt;= name1&quot;
] /* ranges */,
&quot;index_dives_for_eq_ranges&quot;: true,
&quot;rowid_ordered&quot;: false,
&quot;using_mrr&quot;: false,
&quot;index_only&quot;: true,
&quot;rows&quot;: 1,
&quot;cost&quot;: 1.21,
&quot;chosen&quot;: true
}
]
```
回表:
```
&quot;range_scan_alternatives&quot;: [
{
&quot;index&quot;: &quot;name_score&quot;,
&quot;ranges&quot;: [
&quot;name1 &lt;= name &lt;= name1&quot;
] /* ranges */,
&quot;index_dives_for_eq_ranges&quot;: true,
&quot;rowid_ordered&quot;: false,
&quot;using_mrr&quot;: false,
&quot;index_only&quot;: false,
&quot;rows&quot;: 1,
&quot;cost&quot;: 2.21,
&quot;chosen&quot;: true
}
]
```
**问题2**索引除了可以用于加速搜索外还可以在排序时发挥作用你能通过EXPLAIN来证明吗你知道针对排序在什么情况下索引会失效吗
排序使用到索引在执行计划中的体现就是key这一列。如果没有用到索引会在Extra中看到Using filesort代表使用了内存或磁盘进行排序。而具体走内存还是磁盘是由sort_buffer_size和排序数据大小决定的。
排序无法使用到索引的情况有:
- 对于使用联合索引进行排序的场景多个字段排序ASC和DESC混用
- a+b作为联合索引按照a范围查询后按照b排序
- 排序列涉及到的多个字段不属于同一个联合索引;
- 排序列使用了表达式。
其实,这些原因都和索引的结构有关。你可以再有针对性地复习下[第07讲](https://time.geekbang.org/column/article/213342)的聚簇索引和二级索引部分。
### [08 | 判等问题:程序里如何确定你就是你?](https://time.geekbang.org/column/article/213604)
**问题1**在实现equals时我是先通过getClass方法判断两个对象的类型你可能会想到还可以使用instanceof来判断。你能说说这两种实现方式的区别吗
事实上使用getClass和instanceof这两种方案都是可以判断对象类型的。它们的区别就是getClass限制了这两个对象只能属于同一个类而instanceof却允许两个对象是同一个类或其子类。
正是因为这种区别不同的人对这两种方案有不同的喜好争论也很多。在我看来你只需要根据自己的要求去选择。补充说明一下Lombok使用的是instanceof的方案。
**问题2**在“hashCode 和 equals 要配对实现”这一节的例子中我演示了可以通过HashSet的contains方法判断元素是否在HashSet中。那同样是Set的TreeSet其contains方法和HashSet的contains方法有什么区别吗
HashSet基于HashMap数据结构是哈希表。所以HashSet的contains方法其实就是根据hashcode和equals去判断相等的。
TreeSet基于TreeMap数据结构是红黑树。所以TreeSet的contains方法其实就是根据compareTo去判断相等的。
### [09 | 数值计算:注意精度、舍入和溢出问题](https://time.geekbang.org/column/article/213796)
**问题1**[BigDecimal](https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html)提供了8种舍入模式你能通过一些例子说说它们的区别吗
答:@Darren同学的留言非常全面,梳理得也非常清楚了。这里,我对他的留言稍加修改,就是这个问题的答案了。
第一种ROUND_UP舍入远离零的舍入模式在丢弃非零部分之前始终增加数字始终对非零舍弃部分前面的数字加1。 需要注意的是,此舍入模式始终不会减少原始值。
第二种ROUND_DOWN接近零的舍入模式在丢弃某部分之前始终不增加数字从不对舍弃部分前面的数字加1即截断。 需要注意的是,此舍入模式始终不会增加原始值。
第三种ROUND_CEILING接近正无穷大的舍入模式。 如果 BigDecimal 为正,则舍入行为与 ROUND_UP 相同; 如果为负,则舍入行为与 ROUND_DOWN 相同。 需要注意的是,此舍入模式始终不会减少原始值。
第四种ROUND_FLOOR接近负无穷大的舍入模式。 如果 BigDecimal 为正,则舍入行为与 ROUND_DOWN 相同; 如果为负,则舍入行为与 ROUND_UP 相同。 需要注意的是,此舍入模式始终不会增加原始值。
第五种ROUND_HALF_UP向“最接近的”数字舍入。如果舍弃部分 &gt;= 0.5,则舍入行为与 ROUND_UP 相同;否则,舍入行为与 ROUND_DOWN 相同。 需要注意的是,这是我们大多数人在小学时就学过的舍入模式(四舍五入)。
第六种ROUND_HALF_DOWN向“最接近的”数字舍入。如果舍弃部分 &gt; 0.5,则舍入行为与 ROUND_UP 相同;否则,舍入行为与 ROUND_DOWN 相同(五舍六入)。
第七种ROUND_HALF_EVEN向“最接近的”数字舍入。这种算法叫做银行家算法具体规则是四舍六入五则看前一位如果是偶数舍入如果是奇数进位比如5.5 -&gt; 62.5 -&gt; 2。
第八种ROUND_UNNECESSARY假设请求的操作具有精确的结果也就是不需要进行舍入。如果计算结果产生不精确的结果则抛出ArithmeticException。
**问题2**数据库比如MySQL中的浮点数和整型数字你知道应该怎样定义吗又如何实现浮点数的准确计算呢
MySQL中的整数根据能表示的范围有TINYINT、SMALLINT、MEDIUMINT、INTEGER、BIGINT等类型浮点数包括单精度浮点数FLOAT和双精度浮点数DOUBLE和Java中的float/double一样同样有精度问题。
要解决精度问题,主要有两个办法:
- 第一使用DECIMAL类型和那些INT类型一样都属于严格数值数据类型比如DECIMAL(13, 2)或DECIMAL(13, 4)。
- 第二,使用整数保存到分,这种方式容易出错,万一读的时候忘记/100或者是存的时候忘记*100可能会引起重大问题。当然了我们也可以考虑将整数和小数分开保存到两个整数字段。
### [10 | 集合类坑满地的List列表操作](https://time.geekbang.org/column/article/216778)
**问题1**调用类型是Integer的ArrayList的remove方法删除元素传入一个Integer包装类的数字和传入一个int基本类型的数字结果一样吗
传int基本类型的remove方法是按索引值移除返回移除的值传Integer包装类的remove方法是按值移除返回列表移除项目之前是否包含这个值是否移除成功
为了验证两个remove方法重载的区别我们写一段测试代码比较一下
```
private static void removeByIndex(int index) {
List&lt;Integer&gt; list =
IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toCollection(ArrayList::new));
System.out.println(list.remove(index));
System.out.println(list);
}
private static void removeByValue(Integer index) {
List&lt;Integer&gt; list =
IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toCollection(ArrayList::new));
System.out.println(list.remove(index));
System.out.println(list);
}
```
测试一下removeByIndex(4)通过输出可以看到第五项被移除了返回5
```
5
[1, 2, 3, 4, 6, 7, 8, 9, 10]
```
而调用removeByValue(Integer.valueOf(4))通过输出可以看到值4被移除了返回true
```
true
[1, 2, 3, 5, 6, 7, 8, 9, 10]
```
**问题2**循环遍历List调用remove方法删除元素往往会遇到ConcurrentModificationException原因是什么修复方式又是什么呢
原因是remove的时候会改变modCount通过迭代器遍历就会触发ConcurrentModificationException。我们看下ArrayList类内部迭代器的相关源码
```
public E next() {
checkForComodification();
int i = cursor;
if (i &gt;= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i &gt;= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
```
要修复这个问题,有以下两种解决方案。
第一种通过ArrayList的迭代器remove。迭代器的remove方法会维护一个expectedModCount使其与 ArrayList 的modCount保持一致
```
List&lt;String&gt; list =
IntStream.rangeClosed(1, 10).mapToObj(String::valueOf).collect(Collectors.toCollection(ArrayList::new));
for (Iterator&lt;String&gt; iterator = list.iterator(); iterator.hasNext(); ) {
String next = iterator.next();
if (&quot;2&quot;.equals(next)) {
iterator.remove();
}
}
System.out.println(list);
```
第二种直接使用removeIf方法其内部使用了迭代器的remove方法
```
List&lt;String&gt; list =
IntStream.rangeClosed(1, 10).mapToObj(String::valueOf).collect(Collectors.toCollection(ArrayList::new));
list.removeIf(item -&gt; item.equals(&quot;2&quot;));
System.out.println(list);
```
### [11 | 空值处理分不清楚的null和恼人的空指针](https://time.geekbang.org/column/article/216830)
**问题1**ConcurrentHashMap的Key和Value都不能为null而HashMap却可以你知道这么设计的原因是什么吗TreeMap、Hashtable等Map的Key和Value是否支持null呢
原因正如ConcurrentHashMap的作者所说
>
The main reason that nulls arent allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps cant be accommodated. The main one is that if map.get(key) returns null, you cant detect whether the key explicitly maps to null vs the key isnt mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.
如果Value为null会增加二义性也就是说多线程情况下map.get(key)返回null我们无法区分Value原本就是null还是Key没有映射Key也是类似的原因。此外我也更同意他的观点就是普通的Map允许null是否是一个正确的做法也值得商榷因为这会增加犯错的可能性。
Hashtable也是线程安全的所以Key和Value不可以是null。
TreeMap是线程不安全的但是因为需要排序需要进行key的compareTo方法所以Key不能是null而Value可以是null。
**问题2**对于Hibernate框架我们可以使用@DynamicUpdate注解实现字段的动态更新。那么对于MyBatis框架来说要如何实现类似的动态SQL功能实现插入和修改SQL只包含POJO中的非空字段呢
MyBatis可以通过动态SQL实现
```
&lt;select id=&quot;findUser&quot; resultType=&quot;User&quot;&gt;
SELECT * FROM USER
WHERE 1=1
&lt;if test=&quot;name != null&quot;&gt;
AND name like #{name}
&lt;/if&gt;
&lt;if test=&quot;email != null&quot;&gt;
AND email = #{email}
&lt;/if&gt;
&lt;/select&gt;
```
如果使用MyBatisPlus的话实现类似的动态SQL功能会更方便。我们可以直接在字段上加@TableField注解来实现可以设置insertStrategy、updateStrategy、whereStrategy属性。关于这三个属性的使用方式你可以参考如下源码或是[这里](https://mp.baomidou.com/guide/annotation.html#tablefield)的官方文档:
```
/**
* 字段验证策略之 insert: 当insert操作时该字段拼接insert语句时的策略
* IGNORED: 直接拼接 insert into table_a(column) values (#{columnProperty});
* NOT_NULL: insert into table_a(&lt;if test=&quot;columnProperty != null&quot;&gt;column&lt;/if&gt;) values (&lt;if test=&quot;columnProperty != null&quot;&gt;#{columnProperty}&lt;/if&gt;)
* NOT_EMPTY: insert into table_a(&lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;column&lt;/if&gt;) values (&lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;#{columnProperty}&lt;/if&gt;)
*
* @since 3.1.2
*/
FieldStrategy insertStrategy() default FieldStrategy.DEFAULT;
/**
* 字段验证策略之 update: 当更新操作时该字段拼接set语句时的策略
* IGNORED: 直接拼接 update table_a set column=#{columnProperty}, 属性为null/空string都会被set进去
* NOT_NULL: update table_a set &lt;if test=&quot;columnProperty != null&quot;&gt;column=#{columnProperty}&lt;/if&gt;
* NOT_EMPTY: update table_a set &lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;column=#{columnProperty}&lt;/if&gt;
*
* @since 3.1.2
*/
FieldStrategy updateStrategy() default FieldStrategy.DEFAULT;
/**
* 字段验证策略之 where: 表示该字段在拼接where条件时的策略
* IGNORED: 直接拼接 column=#{columnProperty}
* NOT_NULL: &lt;if test=&quot;columnProperty != null&quot;&gt;column=#{columnProperty}&lt;/if&gt;
* NOT_EMPTY: &lt;if test=&quot;columnProperty != null and columnProperty!=''&quot;&gt;column=#{columnProperty}&lt;/if&gt;
*
* @since 3.1.2
*/
FieldStrategy whereStrategy() default FieldStrategy.DEFAULT;
```
### [12 | 异常处理:别让自己在出问题的时候变为瞎子](https://time.geekbang.org/column/article/220230)
**问题1**关于在finally代码块中抛出异常的坑如果在finally代码块中返回值你觉得程序会以try或catch中的返回值为准还是以finally中的返回值为准呢
以finally中的返回值为准。
从语义上来说finally是做方法收尾资源释放处理的我们不建议在finally中有return这样逻辑会很混乱。这是因为实现上finally中的代码块会被复制多份分别放到try和catch调用return和throw异常之前所以finally中如果有返回值会覆盖try中的返回值。
**问题2**对于手动抛出的异常不建议直接使用Exception或RuntimeException通常建议复用JDK中的一些标准异常比如[IllegalArgumentException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalArgumentException.html)、[IllegalStateException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalStateException.html)、[UnsupportedOperationException](https://docs.oracle.com/javase/8/docs/api/java/lang/UnsupportedOperationException.html)。你能说说它们的适用场景,并列出更多常见的可重用标准异常吗?
我们先分别看看IllegalArgumentException、IllegalStateException、UnsupportedOperationException这三种异常的适用场景。
- IllegalArgumentException参数不合法异常适用于传入的参数不符合方法要求的场景。
- IllegalStateException状态不合法异常适用于状态机的状态的无效转换当前逻辑的执行状态不适合进行相应操作等场景。
- UnsupportedOperationException操作不支持异常适用于某个操作在实现或环境下不支持的场景。
还可以重用的异常有IndexOutOfBoundsException、NullPointerException、ConcurrentModificationException等。
以上就是咱们这门课第7~12讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,523 @@
<audio id="audio" title="31 | 加餐1带你吃透课程中Java 8的那些重要知识点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/02/d18d0d701acdb2459e3eeb9644acae02.mp3"></audio>
你好,我是朱晔。
Java 8是目前最常用的JDK版本在增强代码可读性、简化代码方面相比Java 7增加了很多功能比如Lambda、Stream流操作、并行流ParallelStream、Optional可空类型、新日期时间类型等。
这个课程中的所有案例都充分使用了Java 8的各种特性来简化代码。这也就意味着如果你不了解这些特性的话理解课程内的Demo可能会有些困难。因此我将这些特性单独拎了出来组成了两篇加餐。由于后面有单独一节课去讲Java 8的日期时间类型所以这里就不赘述了。
## 如何在项目中用上Lambda表达式和Stream操作
Java 8的特性有很多除了这两篇加餐外我再给你推荐一本全面介绍Java 8的书叫《Java实战第二版》。此外有同学在留言区问怎么把Lambda表达式和Stream操作运用到项目中。其实业务代码中可以使用这些特性的地方有很多。
这里,为了帮助你学习,并把这些特性用到业务开发中,我有三个小建议。
第一从List的操作开始先尝试把遍历List来筛选数据和转换数据的操作使用Stream的filter和map实现这是Stream最常用、最基本的两个API。你可以重点看看接下来两节的内容来入门。
第二使用高级的IDE来写代码以此找到可以利用Java 8语言特性简化代码的地方。比如对于IDEA我们可以把匿名类型使用Lambda替换的检测规则设置为Error级别严重程度
<img src="https://static001.geekbang.org/resource/image/67/77/6707ccf4415c2d8715ed2529cfdec877.png" alt="">
这样运行IDEA的Inspect Code的功能可以在Error级别的错误中看到这个问题引起更多关注帮助我们建立使用Lambda表达式的习惯
<img src="https://static001.geekbang.org/resource/image/50/e4/5062b3ef6ec57ccde0f3f4b182811be4.png" alt="">
第三如果你不知道如何把匿名类转换为Lambda表达式可以借助IDE来重构
<img src="https://static001.geekbang.org/resource/image/5a/e7/5a55c4284e4b10f659b7bcf0129cbde7.png" alt="">
反过来如果你在学习课程内案例时如果感觉阅读Lambda表达式和Stream API比较吃力同样可以借助IDE把Java 8的写法转换为使用循环的写法
<img src="https://static001.geekbang.org/resource/image/98/8a/98828a36d6bb7b7972a647b37a64f08a.png" alt="">
或者是把Lambda表达式替换为匿名类
<img src="https://static001.geekbang.org/resource/image/ee/7c/ee9401683b19e57462cb2574c285d67c.png" alt="">
## Lambda表达式
Lambda表达式的初衷是进一步简化匿名类的语法不过实现上Lambda表达式并不是匿名类的语法糖使Java走向函数式编程。对于匿名类虽然没有类名但还是要给出方法定义。这里有个例子分别使用匿名类和Lambda表达式创建一个线程打印字符串
```
//匿名类
new Thread(new Runnable(){
@Override
public void run(){
System.out.println(&quot;hello1&quot;);
}
}).start();
//Lambda表达式
new Thread(() -&gt; System.out.println(&quot;hello2&quot;)).start();
```
那么Lambda表达式如何匹配Java的类型系统呢
答案就是,函数式接口。
函数式接口是一种只有单一抽象方法的接口,使用@FunctionalInterface来描述,可以隐式地转换成 Lambda 表达式。使用Lambda表达式来实现函数式接口不需要提供类名和方法定义通过一行代码提供函数式接口的实例就可以让函数成为程序中的头等公民可以像普通数据一样作为参数传递而不是作为一个固定的类中的固定方法。
函数式接口到底是什么样的呢java.util.function包中定义了各种函数式接口。比如用于提供数据的Supplier接口就只有一个get抽象方法没有任何入参、有一个返回值
```
@FunctionalInterface
public interface Supplier&lt;T&gt; {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
```
我们可以使用Lambda表达式或方法引用来得到Supplier接口的实例
```
//使用Lambda表达式提供Supplier接口实现返回OK字符串
Supplier&lt;String&gt; stringSupplier = ()-&gt;&quot;OK&quot;;
//使用方法引用提供Supplier接口实现返回空字符串
Supplier&lt;String&gt; supplier = String::new;
```
这样是不是很方便为了帮你掌握函数式接口及其用法我再举几个使用Lambda表达式或方法引用来构建函数的例子
```
//Predicate接口是输入一个参数返回布尔值。我们通过and方法组合两个Predicate条件判断是否值大于0并且是偶数
Predicate&lt;Integer&gt; positiveNumber = i -&gt; i &gt; 0;
Predicate&lt;Integer&gt; evenNumber = i -&gt; i % 2 == 0;
assertTrue(positiveNumber.and(evenNumber).test(2));
//Consumer接口是消费一个数据。我们通过andThen方法组合调用两个Consumer输出两行abcdefg
Consumer&lt;String&gt; println = System.out::println;
println.andThen(println).accept(&quot;abcdefg&quot;);
//Function接口是输入一个数据计算后输出一个数据。我们先把字符串转换为大写然后通过andThen组合另一个Function实现字符串拼接
Function&lt;String, String&gt; upperCase = String::toUpperCase;
Function&lt;String, String&gt; duplicate = s -&gt; s.concat(s);
assertThat(upperCase.andThen(duplicate).apply(&quot;test&quot;), is(&quot;TESTTEST&quot;));
//Supplier是提供一个数据的接口。这里我们实现获取一个随机数
Supplier&lt;Integer&gt; random = ()-&gt;ThreadLocalRandom.current().nextInt();
System.out.println(random.get());
//BinaryOperator是输入两个同类型参数输出一个同类型参数的接口。这里我们通过方法引用获得一个整数加法操作通过Lambda表达式定义一个减法操作然后依次调用
BinaryOperator&lt;Integer&gt; add = Integer::sum;
BinaryOperator&lt;Integer&gt; subtraction = (a, b) -&gt; a - b;
assertThat(subtraction.apply(add.apply(1, 2), 3), is(0));
```
Predicate、Function等函数式接口还使用default关键字实现了几个默认方法。这样一来它们既可以满足函数式接口只有一个抽象方法又能为接口提供额外的功能
```
@FunctionalInterface
public interface Function&lt;T, R&gt; {
R apply(T t);
default &lt;V&gt; Function&lt;V, R&gt; compose(Function&lt;? super V, ? extends T&gt; before) {
Objects.requireNonNull(before);
return (V v) -&gt; apply(before.apply(v));
}
default &lt;V&gt; Function&lt;T, V&gt; andThen(Function&lt;? super R, ? extends V&gt; after) {
Objects.requireNonNull(after);
return (T t) -&gt; after.apply(apply(t));
}
}
```
很明显Lambda表达式给了我们复用代码的更多可能性我们可以把一大段逻辑中变化的部分抽象出函数式接口由外部方法提供函数实现重用方法内的整体逻辑处理。
不过需要注意的是,在自定义函数式接口之前,可以先确认下[java.util.function包](https://docs.oracle.com/javase/8/docs/api/java/util/function/package-summary.html)中的43个标准函数式接口是否能满足需求我们要尽可能重用这些接口因为使用大家熟悉的标准接口可以提高代码的可读性。
## 使用Java 8简化代码
这一部分我会通过几个具体的例子带你感受一下使用Java 8简化代码的三个重要方面
- 使用Stream简化集合操作
- 使用Optional简化判空逻辑
- JDK8结合Lambda和Stream对各种类的增强。
### 使用Stream简化集合操作
Lambda表达式可以帮我们用简短的代码实现方法的定义给了我们复用代码的更多可能性。利用这个特性我们可以把集合的投影、转换、过滤等操作抽象成通用的接口然后通过Lambda表达式传入其具体实现这也就是Stream操作。
我们看一个具体的例子。这里有一段20行左右的代码实现了如下的逻辑
- 把整数列表转换为Point2D列表
- 遍历Point2D列表过滤出Y轴&gt;1的对象
- 计算Point2D点到原点的距离
- 累加所有计算出的距离,并计算距离的平均值。
```
private static double calc(List&lt;Integer&gt; ints) {
//临时中间集合
List&lt;Point2D&gt; point2DList = new ArrayList&lt;&gt;();
for (Integer i : ints) {
point2DList.add(new Point2D.Double((double) i % 3, (double) i / 3));
}
//临时变量,纯粹是为了获得最后结果需要的中间变量
double total = 0;
int count = 0;
for (Point2D point2D : point2DList) {
//过滤
if (point2D.getY() &gt; 1) {
//算距离
double distance = point2D.distance(0, 0);
total += distance;
count++;
}
}
//注意count可能为0的可能
return count &gt;0 ? total / count : 0;
}
```
现在我们可以使用Stream配合Lambda表达式来简化这段代码。简化后一行代码就可以实现这样的逻辑更重要的是代码可读性更强了通过方法名就可以知晓大概是在做什么事情。比如
- map方法传入的是一个Function可以实现对象转换
- filter方法传入一个Predicate实现对象的布尔判断只保留返回true的数据
- mapToDouble用于把对象转换为double
- 通过average方法返回一个OptionalDouble代表可能包含值也可能不包含值的可空double。
下面的第三行代码,就实现了上面方法的所有工作:
```
List&lt;Integer&gt; ints = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
double average = calc(ints);
double streamResult = ints.stream()
.map(i -&gt; new Point2D.Double((double) i % 3, (double) i / 3))
.filter(point -&gt; point.getY() &gt; 1)
.mapToDouble(point -&gt; point.distance(0, 0))
.average()
.orElse(0);
//如何用一行代码来实现,比较一下可读性
assertThat(average, is(streamResult));
```
到这里你可能会问了OptionalDouble又是怎么回事儿
### 有关Optional可空类型
其实类似OptionalDouble、OptionalInt、OptionalLong等是服务于基本类型的可空对象。此外Java8还定义了用于引用类型的Optional类。使用Optional不仅可以避免使用Stream进行级联调用的空指针问题更重要的是它提供了一些实用的方法帮我们避免判空逻辑。
如下是一些例子演示了如何使用Optional来避免空指针以及如何使用它的fluent API简化冗长的if-else判空逻辑
```
@Test(expected = IllegalArgumentException.class)
public void optional() {
//通过get方法获取Optional中的实际值
assertThat(Optional.of(1).get(), is(1));
//通过ofNullable来初始化一个null通过orElse方法实现Optional中无数据的时候返回一个默认值
assertThat(Optional.ofNullable(null).orElse(&quot;A&quot;), is(&quot;A&quot;));
//OptionalDouble是基本类型double的Optional对象isPresent判断有无数据
assertFalse(OptionalDouble.empty().isPresent());
//通过map方法可以对Optional对象进行级联转换不会出现空指针转换后还是一个Optional
assertThat(Optional.of(1).map(Math::incrementExact).get(), is(2));
//通过filter实现Optional中数据的过滤得到一个Optional然后级联使用orElse提供默认值
assertThat(Optional.of(1).filter(integer -&gt; integer % 2 == 0).orElse(null), is(nullValue()));
//通过orElseThrow实现无数据时抛出异常
Optional.empty().orElseThrow(IllegalArgumentException::new);
}
```
我把Optional类的常用方法整理成了一张图你可以对照案例再复习一下
<img src="https://static001.geekbang.org/resource/image/c8/52/c8a901bb16b9fca07ae0fc8bb222b252.jpg" alt="">
### Java 8类对于函数式API的增强
除了Stream之外Java 8中有很多类也都实现了函数式的功能。
比如要通过HashMap实现一个缓存的操作在Java 8之前我们可能会写出这样的getProductAndCache方法先判断缓存中是否有值如果没有值就从数据库搜索取值最后把数据加入缓存。
```
private Map&lt;Long, Product&gt; cache = new ConcurrentHashMap&lt;&gt;();
private Product getProductAndCache(Long id) {
Product product = null;
//Key存在返回Value
if (cache.containsKey(id)) {
product = cache.get(id);
} else {
//不存在则获取Value
//需要遍历数据源查询获得Product
for (Product p : Product.getData()) {
if (p.getId().equals(id)) {
product = p;
break;
}
}
//加入ConcurrentHashMap
if (product != null)
cache.put(id, product);
}
return product;
}
@Test
public void notcoolCache() {
getProductAndCache(1L);
getProductAndCache(100L);
System.out.println(cache);
assertThat(cache.size(), is(1));
assertTrue(cache.containsKey(1L));
}
```
而在Java 8中我们利用ConcurrentHashMap的computeIfAbsent方法用一行代码就可以实现这样的繁琐操作
```
private Product getProductAndCacheCool(Long id) {
return cache.computeIfAbsent(id, i -&gt; //当Key不存在的时候提供一个Function来代表根据Key获取Value的过程
Product.getData().stream()
.filter(p -&gt; p.getId().equals(i)) //过滤
.findFirst() //找第一个得到Optional&lt;Product&gt;
.orElse(null)); //如果找不到Product则使用null
}
@Test
public void coolCache()
{
getProductAndCacheCool(1L);
getProductAndCacheCool(100L);
System.out.println(cache);
assertThat(cache.size(), is(1));
assertTrue(cache.containsKey(1L));
}
```
computeIfAbsent方法在逻辑上相当于
```
if (map.get(key) == null) {
V newValue = mappingFunction.apply(key);
if (newValue != null)
map.put(key, newValue);
}
```
又比如利用Files.walk返回一个Path的流通过两行代码就能实现递归搜索+grep的操作。整个逻辑是递归搜索文件夹查找所有的.java文件然后读取文件每一行内容用正则表达式匹配public class关键字最后输出文件名和这行内容。
```
@Test
public void filesExample() throws IOException {
//无限深度,递归遍历文件夹
try (Stream&lt;Path&gt; pathStream = Files.walk(Paths.get(&quot;.&quot;))) {
pathStream.filter(Files::isRegularFile) //只查普通文件
.filter(FileSystems.getDefault().getPathMatcher(&quot;glob:**/*.java&quot;)::matches) //搜索java源码文件
.flatMap(ThrowingFunction.unchecked(path -&gt;
Files.readAllLines(path).stream() //读取文件内容转换为Stream&lt;List&gt;
.filter(line -&gt; Pattern.compile(&quot;public class&quot;).matcher(line).find()) //使用正则过滤带有public class的行
.map(line -&gt; path.getFileName() + &quot; &gt;&gt; &quot; + line))) //把这行文件内容转换为文件名+行
.forEach(System.out::println); //打印所有的行
}
}
```
输出结果如下:
<img src="https://static001.geekbang.org/resource/image/84/12/84349a90ef4aaf30032d0a8f64ab4512.png" alt="">
我再和你分享一个小技巧吧。因为Files.readAllLines方法会抛出一个受检异常IOException所以我使用了一个自定义的函数式接口用ThrowingFunction包装这个方法把受检异常转换为运行时异常让代码更清晰
```
@FunctionalInterface
public interface ThrowingFunction&lt;T, R, E extends Throwable&gt; {
static &lt;T, R, E extends Throwable&gt; Function&lt;T, R&gt; unchecked(ThrowingFunction&lt;T, R, E&gt; f) {
return t -&gt; {
try {
return f.apply(t);
} catch (Throwable e) {
throw new RuntimeException(e);
}
};
}
R apply(T t) throws E;
}
```
如果用Java 7实现类似逻辑的话大概需要几十行代码你可以尝试下。
## 并行流
前面我们看到的Stream操作都是串行Stream操作只是在一个线程中执行此外Java 8还提供了并行流的功能通过parallel方法一键把Stream转换为并行操作提交到线程池处理。
比如如下代码通过线程池来并行消费处理1到100
```
IntStream.rangeClosed(1,100).parallel().forEach(i-&gt;{
System.out.println(LocalDateTime.now() + &quot; : &quot; + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) { }
});
```
并行流不确保执行顺序并且因为每次处理耗时1秒所以可以看到在8核机器上数组是按照8个一组1秒输出一次
<img src="https://static001.geekbang.org/resource/image/f1/d6/f114d98aa2530c3f7e91b06aaa4ee1d6.png" alt="">
在这个课程中有很多类似使用threadCount个线程对某个方法总计执行taskCount次操作的案例用于演示并发情况下的多线程问题或多线程处理性能。除了会用到并行流我们有时也会使用线程池或直接使用线程进行类似操作。为了方便你对比各种实现这里我一次性给出实现此类操作的五种方式。
为了测试这五种实现方式我们设计一个场景使用20个线程threadCount以并行方式总计执行10000次taskCount操作。因为单个任务单线程执行需要10毫秒任务代码如下也就是每秒吞吐量是100个操作那20个线程QPS是2000执行完10000次操作最少耗时5秒。
```
private void increment(AtomicInteger atomicInteger) {
atomicInteger.incrementAndGet();
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
```
现在我们测试一下这五种方式,是否都可以利用更多的线程并行执行操作。
第一种方式是使用线程。直接把任务按照线程数均匀分割分配到不同的线程执行使用CountDownLatch来阻塞主线程直到所有线程都完成操作。这种方式需要我们自己分割任务
```
private int thread(int taskCount, int threadCount) throws InterruptedException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//使用CountDownLatch来等待所有线程执行完成
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
//使用IntStream把数字直接转为Thread
IntStream.rangeClosed(1, threadCount).mapToObj(i -&gt; new Thread(() -&gt; {
//手动把taskCount分成taskCount份每一份有一个线程执行
IntStream.rangeClosed(1, taskCount / threadCount).forEach(j -&gt; increment(atomicInteger));
//每一个线程处理完成自己那部分数据之后countDown一次
countDownLatch.countDown();
})).forEach(Thread::start);
//等到所有线程执行完成
countDownLatch.await();
//查询计数器当前值
return atomicInteger.get();
}
```
第二种方式是使用Executors.newFixedThreadPool来获得固定线程数的线程池使用execute提交所有任务到线程池执行最后关闭线程池等待所有任务执行完成
```
private int threadpool(int taskCount, int threadCount) throws InterruptedException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//初始化一个线程数量=threadCount的线程池
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
//所有任务直接提交到线程池处理
IntStream.rangeClosed(1, taskCount).forEach(i -&gt; executorService.execute(() -&gt; increment(atomicInteger)));
//提交关闭线程池申请,等待之前所有任务执行完成
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
//查询计数器当前值
return atomicInteger.get();
}
```
第三种方式是使用ForkJoinPool而不是普通线程池执行任务。
ForkJoinPool和传统的ThreadPoolExecutor区别在于前者对于n并行度有n个独立队列后者是共享队列。如果有大量执行耗时比较短的任务ThreadPoolExecutor的单队列就可能会成为瓶颈。这时使用ForkJoinPool性能会更好。
因此ForkJoinPool更适合大任务分割成许多小任务并行执行的场景而ThreadPoolExecutor适合许多独立任务并发执行的场景。
在这里我们先自定义一个具有指定并行数的ForkJoinPool再通过这个ForkJoinPool并行执行操作
```
private int forkjoin(int taskCount, int threadCount) throws InterruptedException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//自定义一个并行度=threadCount的ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
//所有任务直接提交到线程池处理
forkJoinPool.execute(() -&gt; IntStream.rangeClosed(1, taskCount).parallel().forEach(i -&gt; increment(atomicInteger)));
//提交关闭线程池申请,等待之前所有任务执行完成
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//查询计数器当前值
return atomicInteger.get();
}
```
第四种方式是直接使用并行流并行流使用公共的ForkJoinPool也就是ForkJoinPool.commonPool()。
公共的ForkJoinPool默认的并行度是CPU核心数-1原因是对于CPU绑定的任务分配超过CPU个数的线程没有意义。由于并行流还会使用主线程执行任务也会占用一个CPU核心所以公共ForkJoinPool的并行度即使-1也能用满所有CPU核心。
这里我们通过配置强制指定增大了并行数但因为使用的是公共ForkJoinPool所以可能会存在干扰你可以回顾下[第3讲](https://time.geekbang.org/column/article/210337)有关线程池混用产生的问题:
```
private int stream(int taskCount, int threadCount) {
//设置公共ForkJoinPool的并行度
System.setProperty(&quot;java.util.concurrent.ForkJoinPool.common.parallelism&quot;, String.valueOf(threadCount));
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//由于我们设置了公共ForkJoinPool的并行度直接使用parallel提交任务即可
IntStream.rangeClosed(1, taskCount).parallel().forEach(i -&gt; increment(atomicInteger));
//查询计数器当前值
return atomicInteger.get();
}
```
第五种方式是使用CompletableFuture来实现。CompletableFuture.runAsync方法可以指定一个线程池一般会在使用CompletableFuture的时候用到
```
private int completableFuture(int taskCount, int threadCount) throws InterruptedException, ExecutionException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//自定义一个并行度=threadCount的ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
//使用CompletableFuture.runAsync通过指定线程池异步执行任务
CompletableFuture.runAsync(() -&gt; IntStream.rangeClosed(1, taskCount).parallel().forEach(i -&gt; increment(atomicInteger)), forkJoinPool).get();
//查询计数器当前值
return atomicInteger.get();
}
```
上面这五种方法都可以实现类似的效果:
<img src="https://static001.geekbang.org/resource/image/77/cc/77c42149013fd82c18d39b5e0d0292cc.png" alt="">
可以看到这5种方式执行完10000个任务的耗时都在5.4秒到6秒之间。这里的结果只是证明并行度的设置是有效的并不是性能比较。
如果你的程序对性能要求特别敏感建议通过性能测试根据场景决定适合的模式。一般而言使用线程池第二种和直接使用并行流第四种的方式在业务代码中比较常用。但需要注意的是我们通常会重用线程池而不会像Demo中那样在业务逻辑中直接声明新的线程池等操作完成后再关闭。
**另外需要注意的是在上面的例子中我们一定是先运行stream方法再运行forkjoin方法对公共ForkJoinPool默认并行度的修改才能生效。**
这是因为ForkJoinPool类初始化公共线程池是在静态代码块里加载类时就会进行的如果forkjoin方法中先使用了ForkJoinPool即便stream方法中设置了系统属性也不会起作用。因此我的建议是设置ForkJoinPool公共线程池默认并行度的操作应该放在应用启动时设置。
## 重点回顾
今天我和你简单介绍了Java 8中最重要的几个功能包括Lambda表达式、Stream流式操作、Optional可空对象、并行流操作。这些特性可以帮助我们写出简单易懂、可读性更强的代码。特别是使用Stream的链式方法可以用一行代码完成之前几十行代码的工作。
因为Stream的API非常多使用方法也是千变万化因此我会在下一讲和你详细介绍Stream API的一些使用细节。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 检查下代码中是否有使用匿名类以及通过遍历List进行数据过滤、转换和聚合的代码看看能否使用Lambda表达式和Stream来重新实现呢
1. 对于并行流部分的并行消费处理1到100的例子如果把forEach替换为forEachOrdered你觉得会发生什么呢
关于Java 8你还有什么使用心得吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,419 @@
<audio id="audio" title="32 | 加餐2带你吃透课程中Java 8的那些重要知识点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/4c/60f75bbd9b617b4e4991117b1803e64c.mp3"></audio>
你好,我是朱晔。
上一讲的几个例子中其实都涉及了Stream API的最基本使用方法。今天我会与你详细介绍复杂、功能强大的Stream API。
Stream流式操作用于对集合进行投影、转换、过滤、排序等更进一步地这些操作能链式串联在一起使用类似于SQL语句可以大大简化代码。可以说Stream操作是Java 8中最重要的内容也是这个课程大部分代码都会用到的操作。
我先说明下有些案例可能不太好理解建议你对着代码逐一到源码中查看Stream操作的方法定义以及JDK中的代码注释。
## Stream操作详解
为了方便你理解Stream的各种操作以及后面的案例我先把这节课涉及的Stream操作汇总到了一张图中。你可以先熟悉一下。
<img src="https://static001.geekbang.org/resource/image/44/04/44a6f4cb8b413ef62c40a272cb474104.jpg" alt="">
在接下来的讲述中我会围绕订单场景给出如何使用Stream的各种API完成订单的统计、搜索、查询等功能和你一起学习Stream流式操作的各种方法。你可以结合代码中的注释理解案例也可以自己运行源码观察输出。
我们先定义一个订单类、一个订单商品类和一个顾客类用作后续Demo代码的数据结构
```
//订单类
@Data
public class Order {
private Long id;
private Long customerId;//顾客ID
private String customerName;//顾客姓名
private List&lt;OrderItem&gt; orderItemList;//订单商品明细
private Double totalPrice;//总价格
private LocalDateTime placedAt;//下单时间
}
//订单商品类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderItem {
private Long productId;//商品ID
private String productName;//商品名称
private Double productPrice;//商品价格
private Integer productQuantity;//商品数量
}
//顾客类
@Data
@AllArgsConstructor
public class Customer {
private Long id;
private String name;//顾客姓名
}
```
在这里我们有一个orders字段保存了一些模拟数据类型是List<order>。这里,我就不贴出生成模拟数据的代码了。这不会影响你理解后面的代码,你也可以自己下载源码阅读。</order>
### 创建流
要使用流,就要先创建流。创建流一般有五种方式:
- 通过stream方法把List或数组转换为流
- 通过Stream.of方法直接传入多个元素构成一个流
- 通过Stream.iterate方法使用迭代的方式构造一个无限流然后使用limit限制流元素个数
- 通过Stream.generate方法从外部传入一个提供元素的Supplier来构造无限流然后使用limit限制流元素个数
- 通过IntStream或DoubleStream构造基本类型的流。
```
//通过stream方法把List或数组转换为流
@Test
public void stream()
{
Arrays.asList(&quot;a1&quot;, &quot;a2&quot;, &quot;a3&quot;).stream().forEach(System.out::println);
Arrays.stream(new int[]{1, 2, 3}).forEach(System.out::println);
}
//通过Stream.of方法直接传入多个元素构成一个流
@Test
public void of()
{
String[] arr = {&quot;a&quot;, &quot;b&quot;, &quot;c&quot;};
Stream.of(arr).forEach(System.out::println);
Stream.of(&quot;a&quot;, &quot;b&quot;, &quot;c&quot;).forEach(System.out::println);
Stream.of(1, 2, &quot;a&quot;).map(item -&gt; item.getClass().getName()).forEach(System.out::println);
}
//通过Stream.iterate方法使用迭代的方式构造一个无限流然后使用limit限制流元素个数
@Test
public void iterate()
{
Stream.iterate(2, item -&gt; item * 2).limit(10).forEach(System.out::println);
Stream.iterate(BigInteger.ZERO, n -&gt; n.add(BigInteger.TEN)).limit(10).forEach(System.out::println);
}
//通过Stream.generate方法从外部传入一个提供元素的Supplier来构造无限流然后使用limit限制流元素个数
@Test
public void generate()
{
Stream.generate(() -&gt; &quot;test&quot;).limit(3).forEach(System.out::println);
Stream.generate(Math::random).limit(10).forEach(System.out::println);
}
//通过IntStream或DoubleStream构造基本类型的流
@Test
public void primitive()
{
//演示IntStream和DoubleStream
IntStream.range(1, 3).forEach(System.out::println);
IntStream.range(0, 3).mapToObj(i -&gt; &quot;x&quot;).forEach(System.out::println);
IntStream.rangeClosed(1, 3).forEach(System.out::println);
DoubleStream.of(1.1, 2.2, 3.3).forEach(System.out::println);
//各种转换,后面注释代表了输出结果
System.out.println(IntStream.of(1, 2).toArray().getClass()); //class [I
System.out.println(Stream.of(1, 2).mapToInt(Integer::intValue).toArray().getClass()); //class [I
System.out.println(IntStream.of(1, 2).boxed().toArray().getClass()); //class [Ljava.lang.Object;
System.out.println(IntStream.of(1, 2).asDoubleStream().toArray().getClass()); //class [D
System.out.println(IntStream.of(1, 2).asLongStream().toArray().getClass()); //class [J
//注意基本类型流和装箱后的流的区别
Arrays.asList(&quot;a&quot;, &quot;b&quot;, &quot;c&quot;).stream() // Stream&lt;String&gt;
.mapToInt(String::length) // IntStream
.asLongStream() // LongStream
.mapToDouble(x -&gt; x / 10.0) // DoubleStream
.boxed() // Stream&lt;Double&gt;
.mapToLong(x -&gt; 1L) // LongStream
.mapToObj(x -&gt; &quot;&quot;) // Stream&lt;String&gt;
.collect(Collectors.toList());
}
```
### filter
filter方法可以实现过滤操作类似SQL中的where。我们可以使用一行代码通过filter方法实现查询所有订单中最近半年金额大于40的订单通过连续叠加filter方法进行多次条件过滤
```
//最近半年的金额大于40的订单
orders.stream()
.filter(Objects::nonNull) //过滤null值
.filter(order -&gt; order.getPlacedAt().isAfter(LocalDateTime.now().minusMonths(6))) //最近半年的订单
.filter(order -&gt; order.getTotalPrice() &gt; 40) //金额大于40的订单
.forEach(System.out::println);
```
如果不使用Stream的话必然需要一个中间集合来收集过滤后的结果而且所有的过滤条件会堆积在一起代码冗长且不易读。
### map
map操作可以做转换或者说投影类似SQL中的select。为了对比我用两种方式统计订单中所有商品的数量前一种是通过两次遍历实现后一种是通过两次mapToLong+sum方法实现
```
//计算所有订单商品数量
//通过两次遍历实现
LongAdder longAdder = new LongAdder();
orders.stream().forEach(order -&gt;
order.getOrderItemList().forEach(orderItem -&gt; longAdder.add(orderItem.getProductQuantity())));
//使用两次mapToLong+sum方法实现
assertThat(longAdder.longValue(), is(orders.stream().mapToLong(order -&gt;
order.getOrderItemList().stream()
.mapToLong(OrderItem::getProductQuantity).sum()).sum()));
```
显然后一种方式无需中间变量longAdder更直观。
这里再补充一下使用for循环生成数据是我们平时常用的操作也是这个课程会大量用到的。现在我们可以用一行代码使用IntStream配合mapToObj替代for循环来生成数据比如生成10个Product元素构成List
```
//把IntStream通过转换Stream&lt;Project&gt;
System.out.println(IntStream.rangeClosed(1,10)
.mapToObj(i-&gt;new Product((long)i, &quot;product&quot;+i, i*100.0))
.collect(toList()));
```
### flatMap
接下来我们看看flatMap展开或者叫扁平化操作相当于map+flat通过map把每一个元素替换为一个流然后展开这个流。
比如,我们要统计所有订单的总价格,可以有两种方式:
- 直接通过原始商品列表的商品个数*商品单价统计的话可以先把订单通过flatMap展开成商品清单也就是把Order替换为Stream<orderitem>然后对每一个OrderItem用mapToDouble转换获得商品总价最后进行一次sum求和</orderitem>
- 利用flatMapToDouble方法把列表中每一项展开替换为一个DoubleStream也就是直接把每一个订单转换为每一个商品的总价然后求和。
```
//直接展开订单商品进行价格统计
System.out.println(orders.stream()
.flatMap(order -&gt; order.getOrderItemList().stream())
.mapToDouble(item -&gt; item.getProductQuantity() * item.getProductPrice()).sum());
//另一种方式flatMap+mapToDouble=flatMapToDouble
System.out.println(orders.stream()
.flatMapToDouble(order -&gt;
order.getOrderItemList()
.stream().mapToDouble(item -&gt; item.getProductQuantity() * item.getProductPrice()))
.sum());
```
这两种方式可以得到相同的结果,并无本质区别。
### sorted
sorted操作可以用于行内排序的场景类似SQL中的order by。比如要实现大于50元订单的按价格倒序取前5可以通过Order::getTotalPrice方法引用直接指定需要排序的依据字段通过reversed()实现倒序:
```
//大于50的订单,按照订单价格倒序前5
orders.stream().filter(order -&gt; order.getTotalPrice() &gt; 50)
.sorted(comparing(Order::getTotalPrice).reversed())
.limit(5)
.forEach(System.out::println);
```
### distinct
distinct操作的作用是去重类似SQL中的distinct。比如下面的代码实现
- 查询去重后的下单用户。使用map从订单提取出购买用户然后使用distinct去重。
- 查询购买过的商品名。使用flatMap+map提取出订单中所有的商品名然后使用distinct去重。
```
//去重的下单用户
System.out.println(orders.stream().map(order -&gt; order.getCustomerName()).distinct().collect(joining(&quot;,&quot;)));
//所有购买过的商品
System.out.println(orders.stream()
.flatMap(order -&gt; order.getOrderItemList().stream())
.map(OrderItem::getProductName)
.distinct().collect(joining(&quot;,&quot;)));
```
### skip &amp; limit
skip和limit操作用于分页类似MySQL中的limit。其中skip实现跳过一定的项limit用于限制项总数。比如下面的两段代码
- 按照下单时间排序查询前2个订单的顾客姓名和下单时间
- 按照下单时间排序查询第3和第4个订单的顾客姓名和下单时间。
```
//按照下单时间排序查询前2个订单的顾客姓名和下单时间
orders.stream()
.sorted(comparing(Order::getPlacedAt))
.map(order -&gt; order.getCustomerName() + &quot;@&quot; + order.getPlacedAt())
.limit(2).forEach(System.out::println);
//按照下单时间排序查询第3和第4个订单的顾客姓名和下单时间
orders.stream()
.sorted(comparing(Order::getPlacedAt))
.map(order -&gt; order.getCustomerName() + &quot;@&quot; + order.getPlacedAt())
.skip(2).limit(2).forEach(System.out::println);
```
### collect
collect是收集操作对流进行终结终止操作把流导出为我们需要的数据结构。“终结”是指导出后无法再串联使用其他中间操作比如filter、map、flatmap、sorted、distinct、limit、skip。
在Stream操作中collect是最复杂的终结操作比较简单的终结操作还有forEach、toArray、min、max、count、anyMatch等我就不再展开了你可以查询[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html)搜索terminal operation或intermediate operation。
接下来我通过6个案例来演示下几种比较常用的collect操作
- 第一个案例,实现了字符串拼接操作,生成一定位数的随机字符串。
- 第二个案例通过Collectors.toSet静态方法收集为Set去重得到去重后的下单用户再通过Collectors.joining静态方法实现字符串拼接。
- 第三个案例通过Collectors.toCollection静态方法获得指定类型的集合比如把List<order>转换为LinkedList<order></order></order>
- 第四个案例通过Collectors.toMap静态方法将对象快速转换为MapKey是订单ID、Value是下单用户名。
- 第五个案例通过Collectors.toMap静态方法将对象转换为Map。Key是下单用户名Value是下单时间一个用户可能多次下单所以直接在这里进行了合并只获取最近一次的下单时间。
- 第六个案例使用Collectors.summingInt方法对商品数量求和再使用Collectors.averagingInt方法对结果求平均值以统计所有订单平均购买的商品数量。
```
//生成一定位数的随机字符串
System.out.println(random.ints(48, 122)
.filter(i -&gt; (i &lt; 57 || i &gt; 65) &amp;&amp; (i &lt; 90 || i &gt; 97))
.mapToObj(i -&gt; (char) i)
.limit(20)
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
.toString());
//所有下单的用户使用toSet去重后实现字符串拼接
System.out.println(orders.stream()
.map(order -&gt; order.getCustomerName()).collect(toSet())
.stream().collect(joining(&quot;,&quot;, &quot;[&quot;, &quot;]&quot;)));
//用toCollection收集器指定集合类型
System.out.println(orders.stream().limit(2).collect(toCollection(LinkedList::new)).getClass());
//使用toMap获取订单ID+下单用户名的Map
orders.stream()
.collect(toMap(Order::getId, Order::getCustomerName))
.entrySet().forEach(System.out::println);
//使用toMap获取下单用户名+最近一次下单时间的Map
orders.stream()
.collect(toMap(Order::getCustomerName, Order::getPlacedAt, (x, y) -&gt; x.isAfter(y) ? x : y))
.entrySet().forEach(System.out::println);
//订单平均购买的商品数量
System.out.println(orders.stream().collect(averagingInt(order -&gt;
order.getOrderItemList().stream()
.collect(summingInt(OrderItem::getProductQuantity)))));
```
可以看到这6个操作使用Stream方式一行代码就可以实现但使用非Stream方式实现的话都需要几行甚至十几行代码。
有关Collectors类的一些常用静态方法我总结到了一张图中你可以再整理一下思路
<img src="https://static001.geekbang.org/resource/image/5a/de/5af5ba60d7af2c8780b69bc6c71cf3de.png" alt="">
其中groupBy和partitionBy比较复杂我和你举例介绍。
### groupBy
groupBy是分组统计操作类似SQL中的group by子句。它和后面介绍的partitioningBy都是特殊的收集器同样也是终结操作。分组操作比较复杂为帮你理解得更透彻我准备了8个案例
- 第一个案例按照用户名分组使用Collectors.counting方法统计每个人的下单数量再按照下单数量倒序输出。
- 第二个案例按照用户名分组使用Collectors.summingDouble方法统计订单总金额再按总金额倒序输出。
- 第三个案例按照用户名分组使用两次Collectors.summingInt方法统计商品采购数量再按总数量倒序输出。
- 第四个案例统计被采购最多的商品。先通过flatMap把订单转换为商品然后把商品名作为Key、Collectors.summingInt作为Value分组统计采购数量再按Value倒序获取第一个Entry最后查询Key就得到了售出最多的商品。
- 第五个案例同样统计采购最多的商品。相比第四个案例排序Map的方式这次直接使用Collectors.maxBy收集器获得最大的Entry。
- 第六个案例按照用户名分组统计用户下的金额最高的订单。Key是用户名Value是Order直接通过Collectors.maxBy方法拿到金额最高的订单然后通过collectingAndThen实现Optional.get的内容提取最后遍历Key/Value即可。
- 第七个案例根据下单年月分组统计订单ID列表。Key是格式化成年月后的下单时间Value直接通过Collectors.mapping方法进行了转换把订单列表转换为订单ID构成的List。
- 第八个案例,根据下单年月+用户名两次分组统计订单ID列表相比上一个案例多了一次分组操作第二次分组是按照用户名进行分组。
```
//按照用户名分组,统计下单数量
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, counting()))
.entrySet().stream().sorted(Map.Entry.&lt;String, Long&gt;comparingByValue().reversed()).collect(toList()));
//按照用户名分组,统计订单总金额
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, summingDouble(Order::getTotalPrice)))
.entrySet().stream().sorted(Map.Entry.&lt;String, Double&gt;comparingByValue().reversed()).collect(toList()));
//按照用户名分组,统计商品采购数量
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName,
summingInt(order -&gt; order.getOrderItemList().stream()
.collect(summingInt(OrderItem::getProductQuantity)))))
.entrySet().stream().sorted(Map.Entry.&lt;String, Integer&gt;comparingByValue().reversed()).collect(toList()));
//统计最受欢迎的商品,倒序后取第一个
orders.stream()
.flatMap(order -&gt; order.getOrderItemList().stream())
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
.entrySet().stream()
.sorted(Map.Entry.&lt;String, Integer&gt;comparingByValue().reversed())
.map(Map.Entry::getKey)
.findFirst()
.ifPresent(System.out::println);
//统计最受欢迎的商品的另一种方式直接利用maxBy
orders.stream()
.flatMap(order -&gt; order.getOrderItemList().stream())
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
.entrySet().stream()
.collect(maxBy(Map.Entry.comparingByValue()))
.map(Map.Entry::getKey)
.ifPresent(System.out::println);
//按照用户名分组,选用户下的总金额最大的订单
orders.stream().collect(groupingBy(Order::getCustomerName, collectingAndThen(maxBy(comparingDouble(Order::getTotalPrice)), Optional::get)))
.forEach((k, v) -&gt; System.out.println(k + &quot;#&quot; + v.getTotalPrice() + &quot;@&quot; + v.getPlacedAt()));
//根据下单年月分组统计订单ID列表
System.out.println(orders.stream().collect
(groupingBy(order -&gt; order.getPlacedAt().format(DateTimeFormatter.ofPattern(&quot;yyyyMM&quot;)),
mapping(order -&gt; order.getId(), toList()))));
//根据下单年月+用户名两次分组统计订单ID列表
System.out.println(orders.stream().collect
(groupingBy(order -&gt; order.getPlacedAt().format(DateTimeFormatter.ofPattern(&quot;yyyyMM&quot;)),
groupingBy(order -&gt; order.getCustomerName(),
mapping(order -&gt; order.getId(), toList())))));
```
如果不借助Stream转换为普通的Java代码实现这些复杂的操作可能需要几十行代码。
### partitionBy
partitioningBy用于分区分区是特殊的分组只有true和false两组。比如我们把用户按照是否下单进行分区给partitioningBy方法传入一个Predicate作为数据分区的区分输出是Map&lt;Boolean, List&lt;T&gt;&gt;
```
public static &lt;T&gt;
Collector&lt;T, ?, Map&lt;Boolean, List&lt;T&gt;&gt;&gt; partitioningBy(Predicate&lt;? super T&gt; predicate) {
return partitioningBy(predicate, toList());
}
```
测试一下partitioningBy配合anyMatch可以把用户分为下过订单和没下过订单两组
```
//根据是否有下单记录进行分区
System.out.println(Customer.getData().stream().collect(
partitioningBy(customer -&gt; orders.stream().mapToLong(Order::getCustomerId)
.anyMatch(id -&gt; id == customer.getId()))));
```
## 重点回顾
今天我用了大量的篇幅和案例和你展开介绍了Stream中很多具体的流式操作方法。有些案例可能不太好理解我建议你对着代码逐一到源码中查看这些操作的方法定义以及JDK中的代码注释。
最后我建议你思考下在日常工作中还会使用SQL统计哪些信息这些SQL是否也可以用Stream来改写呢Stream的API博大精深但其中又有规律可循。这其中的规律主要就是理清楚这些API传参的函数式接口定义就能搞明白到底是需要我们提供数据、消费数据、还是转换数据等。那掌握Stream的方法便是多测试多练习以强化记忆、加深理解。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 使用Stream可以非常方便地对List做各种操作那有没有什么办法可以实现在整个过程中观察数据变化呢比如我们进行filter+map操作如何观察filter后map的原始数据呢
1. Collectors类提供了很多现成的收集器那我们有没有办法实现自定义的收集器呢比如实现一个MostPopularCollector来得到List中出现次数最多的元素满足下面两个测试用例
```
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector&lt;&gt;()).get(), is(2));
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector&lt;&gt;()).get(), is('c'));
```
关于Java 8你还有什么使用心得吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="33 | 加餐3定位应用问题排错套路很重要" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/89/a386559811a46f95445534120cc39889.mp3"></audio>
你好,我是朱晔。
咱们这个课程已经更新13讲了感谢各位同学一直在坚持学习并在评论区留下了很多高质量的留言。这些留言有的是分享自己曾经踩的坑有的是对课后思考题的详细解答还有的是提出了非常好的问题进一步丰富了这个课程的内容。
有同学说这个课程的案例非常实用都是工作中会遇到的。正如我在开篇词中所说这个课程涉及的100个案例、约130个小坑有40%来自于我经历过或者是见过的200多个线上生产事故剩下的60%来自于我开发业务项目,以及日常审核别人的代码发现的问题。确实,我在整理这些案例上花费了很多精力,也特别感谢各位同学的认可,更希望你们能继续坚持学习,继续在评论区和我交流。
也有同学反馈,排查问题的思路很重要,希望自己遇到问题时,也能够从容、高效地定位到根因。因此,今天这一讲,我就与你说说我在应急排错方面积累的心得。这都是我多年担任技术负责人和架构师自己总结出来的,希望对你有所帮助。当然了,也期待你能留言与我说说,自己平时的排错套路。
## 在不同环境排查问题,有不同的方式
要说排查问题的思路,我们首先得明白是在什么环境排错。
- 如果是在自己的开发环境排查问题那你几乎可以使用任何自己熟悉的工具来排查甚至可以进行单步调试。只要问题能重现排查就不会太困难最多就是把程序调试到JDK或三方类库内部进行分析。
- 如果是在测试环境排查问题相比开发环境少的是调试不过你可以使用JDK自带的jvisualvm或阿里的[Arthas](https://github.com/alibaba/arthas)附加到远程的JVM进程排查问题。另外测试环境允许造数据、造压力模拟我们需要的场景因此遇到偶发问题时我们可以尝试去造一些场景让问题更容易出现方便测试。
- 如果是在生产环境排查问题,往往比较难:一方面,生产环境权限管控严格,一般不允许调试工具从远程附加进程;另一方面,生产环境出现问题要求以恢复为先,难以留出充足的时间去慢慢排查问题。但,因为生产环境的流量真实、访问量大、网络权限管控严格、环境复杂,因此更容易出问题,也是出问题最多的环境。
接下来,我就与你详细说说,如何在生产环境排查问题吧。
## 生产问题的排查很大程度依赖监控
其实,排查问题就像在破案,生产环境出现问题时,因为要尽快恢复应用,就不可能保留完整现场用于排查和测试。因此,是否有充足的信息可以了解过去、还原现场就成了破案的关键。这里说的信息,主要就是日志、监控和快照。
日志就不用多说了,主要注意两点:
- 确保错误、异常信息可以被完整地记录到文件日志中;
- 确保生产上程序的日志级别是INFO以上。记录日志要使用合理的日志优先级DEBUG用于开发调试、INFO用于重要流程信息、WARN用于需要关注的问题、ERROR用于阻断流程的错误。
对于监控,在生产环境排查问题时,首先就需要开发和运维团队做好充足的监控,而且是多个层次的监控。
- 主机层面对CPU、内存、磁盘、网络等资源做监控。如果应用部署在虚拟机或Kubernetes集群中那么除了对物理机做基础资源监控外还要对虚拟机或Pod做同样的监控。监控层数取决于应用的部署方案有一层OS就要做一层监控。
- 网络层面,需要监控专线带宽、交换机基本情况、网络延迟。
- 所有的中间件和存储都要做好监控不仅仅是监控进程对CPU、内存、磁盘IO、网络使用的基本指标更重要的是监控组件内部的一些重要指标。比如著名的监控工具Prometheus就提供了大量的[exporter](https://prometheus.io/docs/instrumenting/exporters/)来对接各种中间件和存储系统。
- 应用层面需要监控JVM进程的类加载、内存、GC、线程等常见指标比如使用[Micrometer](https://micrometer.io/)来做应用监控此外还要确保能够收集、保存应用日志、GC日志。
我们再来看看快照。这里的“快照”是指应用进程在某一时刻的快照。通常情况下我们会为生产环境的Java应用设置-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=…这2个JVM参数用于在出现OOM时保留堆快照。这个课程中我们也多次使用MAT工具来分析堆快照。
了解过去、还原现场后,接下来我们就看看定位问题的套路。
## 分析定位问题的套路
定位问题首先要定位问题出在哪个层次上。比如是Java应用程序自身的问题还是外部因素导致的问题。我们可以先查看程序是否有异常异常信息一般比较具体可以马上定位到大概的问题方向如果是一些资源消耗型的问题可能不会有异常我们可以通过指标监控配合显性问题点来定位。
一般情况下,程序的问题来自以下三个方面。
第一程序发布后的Bug回滚后可以立即解决。这类问题的排查可以回滚后再慢慢分析版本差异。
第二,外部因素,比如主机、中间件或数据库的问题。这类问题的排查方式,按照主机层面的问题、中间件或存储(统称组件)的问题分为两类。
主机层面的问题,可以使用工具排查:
- CPU相关问题可以使用top、vmstat、pidstat、ps等工具排查
- 内存相关问题可以使用free、top、ps、vmstat、cachestat、sar等工具排查
- IO相关问题可以使用lsof、iostat、pidstat、sar、iotop、df、du等工具排查
- 网络相关问题可以使用ifconfig、ip、nslookup、dig、ping、tcpdump、iptables等工具排查。
组件的问题,可以从以下几个方面排查:
- 排查组件所在主机是否有问题;
- 排查组件进程基本情况,观察各种监控指标;
- 查看组件的日志输出,特别是错误日志;
- 进入组件控制台,使用一些命令查看其运作情况。
第三因为系统资源不够造成系统假死的问题通常需要先通过重启和扩容解决问题之后再进行分析不过最好能留一个节点作为现场。系统资源不够一般体现在CPU使用高、内存泄漏或OOM的问题、IO问题、网络相关问题这四个方面。
对于CPU使用高的问题如果现场还在具体的分析流程是
- 首先在Linux服务器上运行top -Hp pid命令来查看进程中哪个线程CPU使用高
- 然后输入大写的P将线程按照 CPU 使用率排序并把明显占用CPU的线程ID转换为16进制
- 最后在jstack命令输出的线程栈中搜索这个线程ID定位出问题的线程当时的调用栈。
如果没有条件直接在服务器上运行top命令的话我们可以用采样的方式定位问题间隔固定秒数比如10秒运行一次jstack命令采样几次后对比采样得出哪些线程始终处于运行状态分析出问题的线程。
如果现场没有了我们可以通过排除法来分析。CPU使用高一般是由下面的因素引起的
- 突发压力。这类问题我们可以通过应用之前的负载均衡的流量或日志量来确认诸如Nginx等反向代理都会记录URL可以依靠代理的Access Log进行细化定位也可以通过监控观察JVM线程数的情况。压力问题导致CPU使用高的情况下如果程序的各资源使用没有明显不正常之后可以通过压测+Profilerjvisualvm就有这个功能进一步定位热点方法如果资源使用不正常比如产生了几千个线程就需要考虑调参。
- GC。这种情况我们可以通过JVM监控GC相关指标、GC Log进行确认。如果确认是GC的压力那么内存使用也很可能会不正常需要按照内存问题分析流程做进一步分析。
- 程序中死循环逻辑或不正常的处理流程。这类问题,我们可以结合应用日志分析。一般情况下,应用执行过程中都会产生一些日志,可以重点关注日志量异常部分。
对于内存泄露或OOM的问题最简单的分析方式就是堆转储后使用MAT分析。堆转储包含了堆现场全貌和线程栈信息一般观察支配树图、直方图就可以马上看到占用大量内存的对象可以快速定位到内存相关问题。这一点我们会在[第5篇加餐](https://time.geekbang.org/column/article/230534)中详细介绍。
需要注意的是Java进程对内存的使用不仅仅是堆区还包括线程使用的内存线程个数*每一个线程的线程栈和元数据区。每一个内存区都可能产生OOM可以结合监控观察线程数、已加载类数量等指标分析。另外我们需要注意看一下JVM参数的设置是否有明显不合理的地方限制了资源使用。
IO相关的问题除非是代码问题引起的资源不释放等问题否则通常都不是由Java进程内部因素引发的。
网络相关的问题一般也是由外部因素引起的。对于连通性问题结合异常信息通常比较容易定位对于性能或瞬断问题可以先尝试使用ping等工具简单判断如果不行再使用tcpdump或Wireshark来分析。
## 分析和定位问题需要注意的九个点
有些时候,我们分析和定位问题时,会陷入误区或是找不到方向。遇到这种情况,你可以借鉴下我的九个心得。
**第一,考虑“鸡”和“蛋”的问题。**比如,发现业务逻辑执行很慢且线程数增多的情况时,我们需要考虑两种可能性:
- 一是程序逻辑有问题或外部依赖慢使得业务逻辑执行慢在访问量不变的情况下需要更多的线程数来应对。比如10TPS的并发原先一次请求1s可以执行完成10个线程可以支撑现在执行完成需要10s那就需要100个线程。
- 二是有可能是请求量增大了使得线程数增多应用本身的CPU资源不足再加上上下文切换问题导致处理变慢了。
出现问题的时候,我们需要结合内部表现和入口流量一起看,确认这里的“慢”到底是根因还是结果。
**第二,考虑通过分类寻找规律。**在定位问题没有头绪的时候,我们可以尝试总结规律。
比如我们有10台应用服务器做负载均衡出问题时可以通过日志分析是否是均匀分布的还是问题都出现在1台机器。又比如应用日志一般会记录线程名称出问题时我们可以分析日志是否集中在某一类线程上。再比如如果发现应用开启了大量TCP连接通过netstat我们可以分析出主要集中连接到哪个服务。
如果能总结出规律,很可能就找到了突破点。
**第三,分析问题需要根据调用拓扑来,不能想当然。**比如我们看到Nginx返回502错误一般可以认为是下游服务的问题导致网关无法完成请求转发。对于下游服务不能想当然就认为是我们的Java程序比如在拓扑上可能Nginx代理的是Kubernetes的Traefik Ingress链路是Nginx-&gt;Traefik-&gt;应用如果一味排查Java程序的健康情况那么始终不会找到根因。
又比如我们虽然使用了Spring Cloud Feign来进行服务调用出现连接超时也不一定就是服务端的问题有可能是客户端通过URL来调用服务端并不是通过Eureka的服务发现实现的客户端负载均衡。换句话说客户端连接的是Nginx代理而不是直接连接应用客户端连接服务出现的超时其实是Nginx代理宕机所致。
**第四,考虑资源限制类问题。**观察各种曲线指标,如果发现曲线慢慢上升然后稳定在一个水平线上,那么一般就是资源达到了限制或瓶颈。
比如在观察网络带宽曲线的时候如果发现带宽上升到120MB左右不动了那么很可能就是打满了1GB的网卡或传输带宽。又比如观察到数据库活跃连接数上升到10个就不动了那么很可能是连接池打满了。观察监控一旦看到任何这样的曲线都要引起重视。
**第五,考虑资源相互影响。**CPU、内存、IO和网络这四类资源就像人的五脏六腑是相辅相成的一个资源出现了明显的瓶颈很可能会引起其他资源的连锁反应。
比如内存泄露后对象无法回收会造成大量Full GC此时CPU会大量消耗在GC上从而引起CPU使用增加。又比如我们经常会把数据缓存在内存队列中进行异步IO处理网络或磁盘出现问题时就很可能会引起内存的暴涨。因此出问题的时候我们要考虑到这一点以避免误判。
**第六,排查网络问题要考虑三个方面,到底是客户端问题,还是服务端问题,还是传输问题。**比如出现数据库访问慢的现象可能是客户端的原因连接池不够导致连接获取慢、GC停顿、CPU占满等也可能是传输环节的问题包括光纤、防火墙、路由表设置等问题也可能是真正的服务端问题需要逐一排查来进行区分。
服务端慢一般可以看到MySQL出慢日志传输慢一般可以通过ping来简单定位排除了这两个可能并且仅仅是部分客户端出现访问慢的情况就需要怀疑是客户端本身的问题。对于第三方系统、服务或存储访问出现慢的情况不能完全假设是服务端的问题。
**第七,快照类工具和趋势类工具需要结合使用。**比如jstat、top、各种监控曲线是趋势类工具可以让我们观察各个指标的变化情况定位大概的问题点而jstack和分析堆快照的MAT是快照类工具用于详细分析某一时刻应用程序某一个点的细节。
一般情况下,我们会先使用趋势类工具来总结规律,再使用快照类工具来分析问题。如果反过来可能就会误判,因为快照类工具反映的只是一个瞬间程序的情况,不能仅仅通过分析单一快照得出结论,如果缺少趋势类工具的帮助,那至少也要提取多个快照来对比。
**第八,不要轻易怀疑监控。**我曾看过一个空难事故的分析,飞行员在空中发现仪表显示飞机所有油箱都处于缺油的状态,他第一时间的怀疑是油表出现故障了,始终不愿意相信是真的缺油,结果飞行不久后引擎就断油熄火了。同样地,在应用出现问题时,我们会查看各种监控系统,但有些时候我们宁愿相信自己的经验,也不相信监控图表的显示。这可能会导致我们完全朝着错误的方向来排查问题。
如果你真的怀疑是监控系统有问题,可以看一下这套监控系统对于不出问题的应用显示是否正常,如果正常那就应该相信监控而不是自己的经验。
**第九,如果因为监控缺失等原因无法定位到根因的话,相同问题就有再出现的风险**,需要做好三项工作:
- 做好日志、监控和快照补漏工作,下次遇到问题时可以定位根因;
- 针对问题的症状做好实时报警,确保出现问题后可以第一时间发现;
- 考虑做一套热备的方案,出现问题后可以第一时间切换到热备系统快速解决问题,同时又可以保留老系统的现场。
## 重点回顾
今天,我和你总结分享了分析生产环境问题的套路。
第一,分析问题一定是需要依据的,靠猜是猜不出来的,需要提前做好基础监控的建设。监控的话,需要在基础运维层、应用层、业务层等多个层次进行。定位问题的时候,我们同样需要参照多个监控层的指标表现综合分析。
第二定位问题要先对原因进行大致分类比如是内部问题还是外部问题、CPU相关问题还是内存相关问题、仅仅是A接口的问题还是整个应用的问题然后再去进一步细化探索一定是从大到小来思考问题在追查问题遇到瓶颈的时候我们可以先退出细节再从大的方面捋一下涉及的点再重新来看问题。
第三,分析问题很多时候靠的是经验,很难找到完整的方法论。遇到重大问题的时候,往往也需要根据直觉来第一时间找到最有可能的点,这里甚至有运气成分。我还和你分享了我的九条经验,建议你在平时解决问题的时候多思考、多总结,提炼出更多自己分析问题的套路和拿手工具。
最后,值得一提的是,定位到问题原因后,我们要做好记录和复盘。每一次故障和问题都是宝贵的资源,复盘不仅仅是记录问题,更重要的是改进。复盘时,我们需要做到以下四点:
- 记录完整的时间线、处理措施、上报流程等信息;
- 分析问题的根本原因;
- 给出短、中、长期改进方案包括但不限于代码改动、SOP、流程并记录跟踪每一个方案进行闭环
- 定期组织团队回顾过去的故障。
## 思考与讨论
1. 如果你现在打开一个App后发现首页展示了一片空白那这到底是客户端兼容性的问题还是服务端的问题呢如果是服务端的问题又如何进一步细化定位呢你有什么分析思路吗
1. 对于分析定位问题,你会做哪些监控或是使用哪些工具呢?
你有没有遇到过什么花了很长时间才定位到的,或是让你印象深刻的问题或事故呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,524 @@
<audio id="audio" title="34 | 加餐4分析定位Java问题一定要用好这些工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/35/ba5d4704e07cf01be6a394dc45bbda35.mp3"></audio>
你好我是朱晔。今天我要和你分享的内容是分析定位Java问题常用的一些工具。
到这里我们的课程更新17讲了已经更新过半了。在学习过程中你会发现我在介绍各种坑的时候并不是直接给出问题的结论而是通过工具来亲眼看到问题。
为什么这么做呢?因为我始终认为,遇到问题尽量不要去猜,一定要眼见为实。只有通过日志、监控或工具真正看到问题,然后再回到代码中进行比对确认,我们才能认为是找到了根本原因。
你可能一开始会比较畏惧使用复杂的工具去排查问题又或者是打开了工具感觉无从下手但是随着实践越来越多对Java程序和各种框架的运作越来越熟悉你会发现使用这些工具越来越顺手。
其实呢,工具只是我们定位问题的手段,要用好工具主要还是得对程序本身的运作有大概的认识,这需要长期的积累。
因此我会通过两篇加餐和你分享4个案例分别展示使用JDK自带的工具来排查JVM参数配置问题、使用Wireshark来分析网络问题、通过MAT来分析内存问题以及使用Arthas来分析CPU使用高的问题。这些案例也只是冰山一角你可以自己再通过些例子进一步学习和探索。
在今天这篇加餐中我们就先学习下如何使用JDK自带工具、Wireshark来分析和定位Java程序的问题吧。
## 使用JDK自带工具查看JVM情况
JDK自带了很多命令行甚至是图形界面工具帮助我们查看JVM的一些信息。比如在我的机器上运行ls命令可以看到JDK 8提供了非常多的工具或程序
<img src="https://static001.geekbang.org/resource/image/22/bd/22456d9186a4f36f83209168b782dbbd.png" alt="">
接下来,我会与你介绍些常用的监控工具。你也可以先通过下面这张图了解下各种工具的基本作用:
<img src="https://static001.geekbang.org/resource/image/b4/0d/b4e8ab0a76a8665879e0fc13964ebc0d.jpg" alt="">
为了测试这些工具我们先来写一段代码启动10个死循环的线程每个线程分配一个10MB左右的字符串然后休眠10秒。可以想象到这个程序会对GC造成压力。
```
//启动10个线程
IntStream.rangeClosed(1, 10).mapToObj(i -&gt; new Thread(() -&gt; {
while (true) {
//每一个线程都是一个死循环休眠10秒打印10M数据
String payload = IntStream.rangeClosed(1, 10000000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString();
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(payload.length());
}
})).forEach(Thread::start);
TimeUnit.HOURS.sleep(1);
```
修改pom.xml配置spring-boot-maven-plugin插件打包的Java程序的main方法类
```
&lt;plugin&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;
&lt;configuration&gt;
&lt;mainClass&gt;org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication
&lt;/mainClass&gt;
&lt;/configuration&gt;
&lt;/plugin&gt;
```
然后使用java -jar启动进程设置JVM参数让堆最小最大都是1GB
```
java -jar common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g
```
完成这些准备工作后我们就可以使用JDK提供的工具来观察分析这个测试程序了。
### jps
首先使用jps得到Java进程列表这会比使用ps来的方便
```
➜ ~ jps
12707
22261 Launcher
23864 common-mistakes-0.0.1-SNAPSHOT.jar
15608 RemoteMavenServer36
23243 Main
23868 Jps
22893 KotlinCompileDaemon
```
### jinfo
然后可以使用jinfo打印JVM的各种参数
```
➜ ~ jinfo 23864
Java System Properties:
#Wed Jan 29 12:49:47 CST 2020
...
user.name=zhuye
path.separator=\:
os.version=10.15.2
java.runtime.name=Java(TM) SE Runtime Environment
file.encoding=UTF-8
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
...
VM Flags:
-XX:CICompilerCount=4 -XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=8 -XX:G1HeapRegionSize=1048576 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MaxNewSize=2576351232 -XX:MinHeapDeltaBytes=1048576 -XX:NonNMethodCodeHeapSize=5835340 -XX:NonProfiledCodeHeapSize=122911450 -XX:ProfiledCodeHeapSize=122911450 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
VM Arguments:
java_command: common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g
java_class_path (initial): common-mistakes-0.0.1-SNAPSHOT.jar
Launcher Type: SUN_STANDARD
```
查看第15行和19行可以发现**我们设置JVM参数的方式不对-Xms1g和-Xmx1g这两个参数被当成了Java程序的启动参数**整个JVM目前最大内存是4GB左右而不是1GB。
因此当我们怀疑JVM的配置很不正常的时候要第一时间使用工具来确认参数。除了使用工具确认JVM参数外你也可以打印VM参数和程序参数
```
System.out.println(&quot;VM options&quot;);
System.out.println(ManagementFactory.getRuntimeMXBean().getInputArguments().stream().collect(Collectors.joining(System.lineSeparator())));
System.out.println(&quot;Program arguments&quot;);
System.out.println(Arrays.stream(args).collect(Collectors.joining(System.lineSeparator())));
```
把JVM参数放到-jar之前重新启动程序可以看到如下输出从输出也可以确认这次JVM参数的配置正确了
```
➜ target git:(master) ✗ java -Xms1g -Xmx1g -jar common-mistakes-0.0.1-SNAPSHOT.jar test
VM options
-Xms1g
-Xmx1g
Program arguments
test
```
### jvisualvm
然后启动另一个重量级工具jvisualvm观察一下程序可以在概述面板再次确认JVM参数设置成功了
<img src="https://static001.geekbang.org/resource/image/4d/e4/4d8a600072b0b1aea3943dee584c72e4.png" alt="">
继续观察监视面板可以看到JVM的GC活动基本是10秒发生一次堆内存在250MB到900MB之间波动活动线程数是22。我们可以在监视面板看到JVM的基本情况也可以直接在这里进行手动GC和堆Dump操作
<img src="https://static001.geekbang.org/resource/image/5b/02/5be531e51f6e49d5511d419c90b29302.png" alt="">
### jconsole
如果希望看到各个内存区的GC曲线图可以使用jconsole观察。jconsole也是一个综合性图形界面监控工具比jvisualvm更方便的一点是可以用曲线的形式监控各种数据包括MBean中的属性值
<img src="https://static001.geekbang.org/resource/image/6b/12/6b4c08d384eea532842d386638dddb12.png" alt="">
### jstat
同样如果没有条件使用图形界面毕竟在Linux服务器上我们主要使用命令行工具又希望看到GC趋势的话我们可以使用jstat工具。
jstat工具允许以固定的监控频次输出JVM的各种监控指标比如使用-gcutil输出GC和内存占用汇总信息每隔5秒输出一次输出100次可以看到Young GC比较频繁而Full GC基本10秒一次
```
➜ ~ jstat -gcutil 23940 5000 100
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 100.00 0.36 87.63 94.30 81.06 539 14.021 33 3.972 837 0.976 18.968
0.00 100.00 0.60 69.51 94.30 81.06 540 14.029 33 3.972 839 0.978 18.979
0.00 0.00 0.50 99.81 94.27 81.03 548 14.143 34 4.002 840 0.981 19.126
0.00 100.00 0.59 70.47 94.27 81.03 549 14.177 34 4.002 844 0.985 19.164
0.00 100.00 0.57 99.85 94.32 81.09 550 14.204 34 4.002 845 0.990 19.196
0.00 100.00 0.65 77.69 94.32 81.09 559 14.469 36 4.198 847 0.993 19.659
0.00 100.00 0.65 77.69 94.32 81.09 559 14.469 36 4.198 847 0.993 19.659
0.00 100.00 0.70 35.54 94.32 81.09 567 14.763 37 4.378 853 1.001 20.142
0.00 100.00 0.70 41.22 94.32 81.09 567 14.763 37 4.378 853 1.001 20.142
0.00 100.00 1.89 96.76 94.32 81.09 574 14.943 38 4.487 859 1.007 20.438
0.00 100.00 1.39 39.20 94.32 81.09 575 14.946 38 4.487 861 1.010 20.442
```
>
其中S0表示Survivor0区占用百分比S1表示Survivor1区占用百分比E表示Eden区占用百分比O表示老年代占用百分比M表示元数据区占用百分比YGC表示年轻代回收次数YGCT表示年轻代回收耗时FGC表示老年代回收次数FGCT表示老年代回收耗时。
jstat命令的参数众多包含-class、-compiler、-gc等。Java 8、Linux/Unix平台jstat工具的完整介绍你可以查看[这里](https://docs.oracle.com/javase/8/docs/technotes/tools/#monitor)。jstat定时输出的特性可以方便我们持续观察程序的各项指标。
继续来到线程面板可以看到大量以Thread开头的线程基本都是有节奏的10秒运行一下其他时间都在休眠和我们的代码逻辑匹配
<img src="https://static001.geekbang.org/resource/image/7a/85/7a1616295b4ec51c56437d2a92652185.png" alt="">
点击面板的线程Dump按钮可以查看线程瞬时的线程栈
<img src="https://static001.geekbang.org/resource/image/0d/00/0ddcd3348d1c8b0bba16736f9221a900.png" alt="">
### jstack
通过命令行工具jstack也可以实现抓取线程栈的操作
```
➜ ~ jstack 23940
2020-01-29 13:08:15
Full thread dump Java HotSpot(TM) 64-Bit Server VM (11.0.3+12-LTS mixed mode):
...
&quot;main&quot; #1 prio=5 os_prio=31 cpu=440.66ms elapsed=574.86s tid=0x00007ffdd9800000 nid=0x2803 waiting on condition [0x0000700003849000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(java.base@11.0.3/Native Method)
at java.lang.Thread.sleep(java.base@11.0.3/Thread.java:339)
at java.util.concurrent.TimeUnit.sleep(java.base@11.0.3/TimeUnit.java:446)
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication.main(CommonMistakesApplication.java:41)
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(java.base@11.0.3/Native Method)
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke(java.base@11.0.3/NativeMethodAccessorImpl.java:62)
at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(java.base@11.0.3/DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(java.base@11.0.3/Method.java:566)
at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
at org.springframework.boot.loader.Launcher.launch(Launcher.java:51)
at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:52)
&quot;Thread-1&quot; #13 prio=5 os_prio=31 cpu=17851.77ms elapsed=574.41s tid=0x00007ffdda029000 nid=0x9803 waiting on condition [0x000070000539d000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(java.base@11.0.3/Native Method)
at java.lang.Thread.sleep(java.base@11.0.3/Thread.java:339)
at java.util.concurrent.TimeUnit.sleep(java.base@11.0.3/TimeUnit.java:446)
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication.lambda$null$1(CommonMistakesApplication.java:33)
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication$$Lambda$41/0x00000008000a8c40.run(Unknown Source)
at java.lang.Thread.run(java.base@11.0.3/Thread.java:834)
...
```
抓取后可以使用类似[fastthread](https://fastthread.io/)这样的在线分析工具来分析线程栈。
### jcmd
最后我们来看一下Java HotSpot虚拟机的NMT功能。
通过NMT我们可以观察细粒度内存使用情况设置-XX:NativeMemoryTracking=summary/detail可以开启NMT功能开启后可以使用jcmd工具查看NMT数据。
我们重新启动一次程序这次加上JVM参数以detail方式开启NMT
```
-Xms1g -Xmx1g -XX:ThreadStackSize=256k -XX:NativeMemoryTracking=detail
```
在这里,我们还增加了-XX:ThreadStackSize参数并将其值设置为256k也就是期望把线程栈设置为256KB。我们通过NMT观察一下设置是否成功。
启动程序后执行如下jcmd命令以概要形式输出NMT结果。可以看到**当前有32个线程线程栈总共保留了差不多4GB左右的内存**。我们明明配置线程栈最大256KB啊为什么会出现4GB这么夸张的数字呢到底哪里出了问题呢
```
➜ ~ jcmd 24404 VM.native_memory summary
24404:
Native Memory Tracking:
Total: reserved=6635310KB, committed=5337110KB
- Java Heap (reserved=1048576KB, committed=1048576KB)
(mmap: reserved=1048576KB, committed=1048576KB)
- Class (reserved=1066233KB, committed=15097KB)
(classes #902)
(malloc=9465KB #908)
(mmap: reserved=1056768KB, committed=5632KB)
- Thread (reserved=4209797KB, committed=4209797KB)
(thread #32)
(stack: reserved=4209664KB, committed=4209664KB)
(malloc=96KB #165)
(arena=37KB #59)
- Code (reserved=249823KB, committed=2759KB)
(malloc=223KB #730)
(mmap: reserved=249600KB, committed=2536KB)
- GC (reserved=48700KB, committed=48700KB)
(malloc=10384KB #135)
(mmap: reserved=38316KB, committed=38316KB)
- Compiler (reserved=186KB, committed=186KB)
(malloc=56KB #105)
(arena=131KB #7)
- Internal (reserved=9693KB, committed=9693KB)
(malloc=9661KB #2585)
(mmap: reserved=32KB, committed=32KB)
- Symbol (reserved=2021KB, committed=2021KB)
(malloc=1182KB #334)
(arena=839KB #1)
- Native Memory Tracking (reserved=85KB, committed=85KB)
(malloc=5KB #53)
(tracking overhead=80KB)
- Arena Chunk (reserved=196KB, committed=196KB)
(malloc=196KB)
```
重新以VM.native_memory detail参数运行jcmd
```
jcmd 24404 VM.native_memory detail
```
可以看到,**有16个可疑线程每一个线程保留了262144KB内存也就是256MB**通过下图红框可以看到使用关键字262144KB for Thread Stack from搜索到了16个结果
<img src="https://static001.geekbang.org/resource/image/f2/6b/f24869cbd1190c508e085c9f3400d06b.png" alt="">
其实ThreadStackSize参数的单位是KB**所以我们如果要设置线程栈256KB那么应该设置256而不是256k**。重新设置正确的参数后使用jcmd再次验证下
<img src="https://static001.geekbang.org/resource/image/d7/c9/d7228ec216003d31064698e7e16c81c9.png" alt="">
除了用于查看NMT外jcmd还有许多功能。我们可以通过help看到它的所有功能
```
jcmd 24781 help
```
对于其中每一种功能我们都可以进一步使用help来查看介绍。比如使用GC.heap_info命令可以打印Java堆的一些信息
```
jcmd 24781 help GC.heap_info
```
除了jps、jinfo、jcmd、jstack、jstat、jconsole、jvisualvm外JDK中还有一些工具你可以通过[官方文档](https://docs.oracle.com/javase/8/docs/technotes/tools/)查看完整介绍。
## 使用Wireshark分析SQL批量插入慢的问题
我之前遇到过这样一个案例有一个数据导入程序需要导入大量的数据开发同学就想到了使用Spring JdbcTemplate的批量操作功能进行数据批量导入但是发现性能非常差和普通的单条SQL执行性能差不多。
我们重现下这个案例。启动程序后首先创建一个testuser表其中只有一列name然后使用JdbcTemplate的batchUpdate方法批量插入10000条记录到testuser表
```
@SpringBootApplication
@Slf4j
public class BatchInsertAppliation implements CommandLineRunner {
@Autowired
private JdbcTemplate jdbcTemplate;
public static void main(String[] args) {
SpringApplication.run(BatchInsertApplication.class, args);
}
@PostConstruct
public void init() {
//初始化表
jdbcTemplate.execute(&quot;drop table IF EXISTS `testuser`;&quot;);
jdbcTemplate.execute(&quot;create TABLE `testuser` (\n&quot; +
&quot; `id` bigint(20) NOT NULL AUTO_INCREMENT,\n&quot; +
&quot; `name` varchar(255) NOT NULL,\n&quot; +
&quot; PRIMARY KEY (`id`)\n&quot; +
&quot;) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;&quot;);
}
@Override
public void run(String... args) {
long begin = System.currentTimeMillis();
String sql = &quot;INSERT INTO `testuser` (`name`) VALUES (?)&quot;;
//使用JDBC批量更新
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
//第一个参数(索引从1开始)也就是name列赋值
preparedStatement.setString(1, &quot;usera&quot; + i);
}
@Override
public int getBatchSize() {
//批次大小为10000
return 10000;
}
});
log.info(&quot;took : {} ms&quot;, System.currentTimeMillis() - begin);
}
}
```
执行程序后可以看到1万条数据插入耗时26秒
```
[14:44:19.094] [main] [INFO ] [o.g.t.c.t.network.BatchInsertApplication:52 ] - took : 26144 ms
```
其实对于批量操作我们希望程序可以把多条insert SQL语句合并成一条或至少是一次性提交多条语句到数据库以减少和MySQL交互次数、提高性能。那么我们的程序是这样运作的吗
我在[加餐3](https://time.geekbang.org/column/article/221982)中提到一条原则“分析问题一定是需要依据的靠猜是猜不出来的”。现在我们就使用网络分析工具Wireshark来分析一下这个案例眼见为实。
首先,我们可以在[这里](https://www.wireshark.org/download.html)下载Wireshark启动后选择某个需要捕获的网卡。由于我们连接的是本地的MySQL因此选择loopback回环网卡
<img src="https://static001.geekbang.org/resource/image/d7/9b/d7c3cc2d997990d0c4b94f72f1679c9b.png" alt="">
然后Wireshark捕捉这个网卡的所有网络流量。我们可以在上方的显示过滤栏输入tcp.port == 6657来过滤出所有6657端口的TCP请求因为我们是通过6657端口连接MySQL的
可以看到程序运行期间和MySQL有大量交互。因为Wireshark直接把TCP数据包解析为了MySQL协议所以下方窗口可以直接显示MySQL请求的SQL查询语句。**我们看到testuser表的每次insert操作插入的都是一行记录**
<img src="https://static001.geekbang.org/resource/image/bc/a2/bcb987cab3cccf4d8729cfe44f01a2a2.png" alt="">
如果列表中的Protocol没有显示MySQL的话你可以手动点击Analyze菜单的Decode As菜单然后加一条规则把6657端口设置为MySQL协议
<img src="https://static001.geekbang.org/resource/image/6a/f2/6ae982e2013cf1c60300332068b58cf2.png" alt="">
这就说明我们的程序并不是在做批量插入操作和普通的单条循环插入没有区别。调试程序进入ClientPreparedStatement类可以看到执行批量操作的是executeBatchInternal方法。executeBatchInternal方法的源码如下
```
@Override
protected long[] executeBatchInternal() throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
if (this.connection.isReadOnly()) {
throw new SQLException(Messages.getString(&quot;PreparedStatement.25&quot;) + Messages.getString(&quot;PreparedStatement.26&quot;),
MysqlErrorNumbers.SQL_STATE_ILLEGAL_ARGUMENT);
}
if (this.query.getBatchedArgs() == null || this.query.getBatchedArgs().size() == 0) {
return new long[0];
}
// we timeout the entire batch, not individual statements
int batchTimeout = getTimeoutInMillis();
setTimeoutInMillis(0);
resetCancelledState();
try {
statementBegins();
clearWarnings();
if (!this.batchHasPlainStatements &amp;&amp; this.rewriteBatchedStatements.getValue()) {
if (((PreparedQuery&lt;?&gt;) this.query).getParseInfo().canRewriteAsMultiValueInsertAtSqlLevel()) {
return executeBatchedInserts(batchTimeout);
}
if (!this.batchHasPlainStatements &amp;&amp; this.query.getBatchedArgs() != null
&amp;&amp; this.query.getBatchedArgs().size() &gt; 3 /* cost of option setting rt-wise */) {
return executePreparedBatchAsMultiStatement(batchTimeout);
}
}
return executeBatchSerially(batchTimeout);
} finally {
this.query.getStatementExecuting().set(false);
clearBatch();
}
}
}
```
注意第18行判断了rewriteBatchedStatements参数是否为true是才会开启批量的优化。优化方式有2种
- 如果有条件的话优先把insert语句优化为一条语句也就是executeBatchedInserts方法
- 如果不行的话再尝试把insert语句优化为多条语句一起提交也就是executePreparedBatchAsMultiStatement方法。
到这里就明朗了实现批量提交优化的关键在于rewriteBatchedStatements参数。我们修改连接字符串并将其值设置为true
```
spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&amp;useSSL=false&amp;rewriteBatchedStatements=true
```
重新按照之前的步骤打开Wireshark验证可以看到
- 这次insert SQL语句被拼接成了一条语句如第二个红框所示
- 这个TCP包因为太大被分割成了11个片段传输#699请求是最后一个片段其实际内容是insert语句的最后一部分内容如第一和第三个红框显示
<img src="https://static001.geekbang.org/resource/image/3b/bc/3b7406c96a90e454a00e3c8ba82ecfbc.png" alt="">
为了查看整个TCP连接的所有数据包你可以在请求上点击右键选择Follow-&gt;TCP Stream
<img src="https://static001.geekbang.org/resource/image/5b/c2/5b18a8c6c227df50ad493f5aa546f9c2.png" alt="">
打开后可以看到从MySQL认证开始到insert语句的所有数据包的内容
<img src="https://static001.geekbang.org/resource/image/e1/5a/e154da637a2b44a65f9257beb842575a.png" alt="">
查看最开始的握手数据包可以发现TCP的最大分段大小MSS是16344字节而我们的MySQL超长insert的数据一共138933字节因此被分成了11段传输其中最大的一段是16332字节低于MSS要求的16344字节。
<img src="https://static001.geekbang.org/resource/image/3e/9e/3e66a004fd4b7dba14047751a57e089e.png" alt="">
最后可以看到插入1万条数据仅耗时253毫秒性能提升了100倍
```
[20:19:30.185] [main] [INFO ] [o.g.t.c.t.network.BatchInsertApplication:52 ] - took : 253 ms
```
虽然我们一直在使用MySQL但我们很少会考虑MySQL Connector Java是怎么和MySQL交互的实际发送给MySQL的SQL语句又是怎样的。有没有感觉到MySQL协议其实并不遥远我们完全可以使用Wireshark来观察、分析应用程序与MySQL交互的整个流程。
## 重点回顾
今天我就使用JDK自带工具查看JVM情况、使用Wireshark分析SQL批量插入慢的问题和你展示了一些工具及其用法。
首先JDK自带的一些监控和故障诊断工具中有命令行工具也有图形工具。其中命令行工具更适合在服务器上使用图形界面工具用于本地观察数据更直观。为了帮助你用好这些工具我们带你使用这些工具分析了程序错误设置JVM参数的两个问题并且观察了GC工作的情况。
然后我们使用Wireshark分析了MySQL批量insert操作慢的问题。我们看到通过Wireshark分析网络包可以让一切变得如此透明。因此学好Wireshark对我们排查C/S网络程序的Bug或性能问题会有非常大的帮助。
比如遇到诸如Connection reset、Broken pipe等网络问题的时候你可以利用Wireshark来定位问题观察客户端和服务端之间到底出了什么问题。
此外如果你需要开发网络程序的话Wireshark更是分析协议、确认程序是否正确实现的必备工具。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. JDK中还有一个jmap工具我们会使用jmap -dump命令来进行堆转储。那么这条命令和jmap -dump:live有什么区别呢你能否设计一个实验来证明下它们的区别呢
1. 你有没有想过客户端是如何和MySQL进行认证的呢你能否对照[MySQL的文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake)使用Wireshark观察分析这一过程呢
在平时工作中你还会使用什么工具来分析排查Java应用程序的问题呢我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,283 @@
<audio id="audio" title="35 | 加餐5分析定位Java问题一定要用好这些工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/c0/07f593c7a0cf0e1ce8ddab21b56528c0.mp3"></audio>
你好,我是朱晔。
在[上一篇加餐](https://time.geekbang.org/column/article/224816)中我们介绍了使用JDK内置的一些工具、网络抓包工具Wireshark去分析、定位Java程序的问题。很多同学看完这一讲留言反馈说是“打开了一片新天地之前没有关注过JVM”“利用JVM工具发现了生产OOM的原因”。
其实,工具正是帮助我们深入到框架和组件内部,了解其运作方式和原理的重要抓手。所以,我们一定要用好它们。
今天我继续和你介绍如何使用JVM堆转储的工具MAT来分析OOM问题以及如何使用全能的故障诊断工具Arthas来分析、定位高CPU问题。
## 使用MAT分析OOM问题
对于排查OOM问题、分析程序堆内存使用情况最好的方式就是分析堆转储。
堆转储包含了堆现场全貌和线程栈信息Java 6 Update 14开始包含。我们在上一篇加餐中看到使用jstat等工具虽然可以观察堆内存使用情况的变化但是对程序内到底有多少对象、哪些是大对象还一无所知也就是说只能看到问题但无法定位问题。而堆转储就好似得到了病人在某个瞬间的全景核磁影像可以拿着慢慢分析。
Java的OutOfMemoryError是比较严重的问题需要分析出根因所以对生产应用一般都会这样设置JVM参数方便发生OOM时进行堆转储
```
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
```
上一篇加餐中我们提到的jvisualvm工具同样可以进行一键堆转储后直接打开这个dump查看。但是jvisualvm的堆转储分析功能并不是很强大只能查看类使用内存的直方图无法有效跟踪内存使用的引用关系所以我更推荐使用Eclipse的Memory Analyzer也叫做MAT做堆转储的分析。你可以点击[这个链接](https://www.eclipse.org/mat/)下载MAT。
使用MAT分析OOM问题一般可以按照以下思路进行
1. 通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
1. 查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
1. 配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
1. 辅助使用查看线程栈来看OOM问题是否和过多线程有关甚至可以在线程栈看到OOM最后一刻出现异常的线程。
比如我手头有一个OOM后得到的转储文件java_pid29569.hprof现在要使用MAT的直方图、支配树、线程栈、OQL等功能来分析此次OOM的原因。
首先用MAT打开后先进入的是概览信息界面可以看到整个堆是437.6MB
<img src="https://static001.geekbang.org/resource/image/63/61/63ecdaf5ff7ac431f0d05661855b2e61.png" alt="">
那么这437.6MB都是什么对象呢?
如图所示工具栏的第二个按钮可以打开直方图直方图按照类型进行分组列出了每个类有多少个实例以及占用的内存。可以看到char[]字节数组占用内存最多对象数量也很多结合第二位的String类型对象数量也很多大概可以猜出String使用char[]作为实际数据存储程序可能是被字符串占满了内存导致OOM。
<img src="https://static001.geekbang.org/resource/image/0b/b9/0b3ca076b31a2d571a47c64d622b0db9.png" alt="">
我们继续分析下,到底是不是这样呢。
在char[]上点击右键选择List objects-&gt;with incoming references就可以列出所有的char[]实例以及每个char[]的整个引用关系链:
<img src="https://static001.geekbang.org/resource/image/f1/a3/f162fb9c6505dc9a8f1ea9900437ada3.png" alt="">
随机展开一个char[],如下图所示:
<img src="https://static001.geekbang.org/resource/image/dd/ac/dd4cb44ad54edee3a51f56a646c5f2ac.png" alt="">
接下来我们按照红色框中的引用链来查看尝试找到这些大char[]的来源:
- 在①处看到这些char[]几乎都是10000个字符、占用20000字节左右char是UTF-16每一个字符占用2字节
- 在②处看到char[]被String的value字段引用说明char[]来自字符串;
- 在③处看到String被ArrayList的elementData字段引用说明这些字符串加入了一个ArrayList中
- 在④处看到ArrayList又被FooService的data字段引用这个ArrayList整个RetainedHeap列的值是431MB。
Retained Heap深堆代表对象本身和对象关联的对象占用的内存Shallow Heap浅堆代表对象本身占用的内存。比如我们的FooService中的data这个ArrayList对象本身只有16字节但是其所有关联的对象占用了431MB内存。这些就可以说明肯定有哪里在不断向这个List中添加String数据导致了OOM。
左侧的蓝色框可以查看每一个实例的内部属性图中显示FooService有一个data属性类型是ArrayList。
如果我们希望看到字符串完整内容的话可以右键选择Copy-&gt;Value把值复制到剪贴板或保存到文件中
<img src="https://static001.geekbang.org/resource/image/cc/8f/cc1d53eb9570582da415c1aec5cc228f.png" alt="">
这里我们复制出的是10000个字符a下图红色部分可以看到。对于真实案例查看大字符串、大数据的实际内容对于识别数据来源有很大意义
<img src="https://static001.geekbang.org/resource/image/7b/a0/7b3198574113fecdd2a7de8cde8994a0.png" alt="">
看到这些,我们已经基本可以还原出真实的代码是怎样的了。
其实我们之前使用直方图定位FooService已经走了些弯路。你可以点击工具栏中第三个按钮下图左上角的红框所示进入支配树界面有关支配树的具体概念参考[这里](https://help.eclipse.org/2020-03/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Fconcepts%2Fdominatortree.html&amp;resultof%3D%2522%2564%256f%256d%2569%256e%2561%2574%256f%2572%2522%2520%2522%2564%256f%256d%2569%256e%2522%2520%2522%2574%2572%2565%2565%2522%2520)。这个界面会按照对象保留的Retained Heap倒序直接列出占用内存最大的对象。
可以看到第一位就是FooService整个路径是FooSerice-&gt;ArrayList-&gt;Object[]-&gt;String-&gt;char[]蓝色框部分一共有21523个字符串绿色方框部分
<img src="https://static001.geekbang.org/resource/image/7a/57/7adafa4178a4c72f8621b7eb49ee2757.png" alt="">
这样我们就从内存角度定位到FooService是根源了。那么OOM的时候FooService是在执行什么逻辑呢
为解决这个问题我们可以点击工具栏的第五个按钮下图红色框所示。打开线程视图首先看到的就是一个名为main的线程Name列展开后果然发现了FooService
<img src="https://static001.geekbang.org/resource/image/3a/ce/3a2c3d159e1599d906cc428d812cccce.png" alt="">
先执行的方法先入栈所以线程栈最上面是线程当前执行的方法逐一往下看能看到整个调用路径。因为我们希望了解FooService.oom()方法看看是谁在调用它它的内部又调用了谁所以选择以FooService.oom()方法(蓝色框)为起点来分析这个调用栈。
往下看整个绿色框部分oom()方法被OOMApplication的run方法调用而这个run方法又被SpringAppliction.callRunner方法调用。看到参数中的CommandLineRunner你应该能想到OOMApplication其实是实现了CommandLineRunner接口所以是SpringBoot应用程序启动后执行的。
以FooService为起点往上看从紫色框中的Collectors和IntPipeline你大概也可以猜出这些字符串是由Stream操作产生的。再往上看可以发现在StringBuilder的append操作的时候出现了OutOfMemoryError异常黑色框部分说明这这个线程抛出了OOM异常。
我们看到整个程序是Spring Boot应用程序那么FooService是不是Spring的Bean呢又是不是单例呢如果能分析出这点的话就更能确认是因为反复调用同一个FooService的oom方法然后导致其内部的ArrayList不断增加数据的。
点击工具栏的第四个按钮如下图红框所示来到OQL界面。在这个界面我们可以使用类似SQL的语法在dump中搜索数据你可以直接在MAT帮助菜单搜索OQL Syntax来查看OQL的详细语法
比如输入如下语句搜索FooService的实例
```
SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService
```
可以看到只有一个实例然后我们通过List objects功能搜索引用FooService的对象
<img src="https://static001.geekbang.org/resource/image/19/43/1973846815bd9d78f85bef05b499e843.png" alt="">
得到以下结果:
<img src="https://static001.geekbang.org/resource/image/07/a8/07e1216a6cc93bd146535b5809649ea8.png" alt="">
可以看到,一共两处引用:
- 第一处是OOMApplication使用了FooService这个我们已经知道了。
- 第二处是一个ConcurrentHashMap。可以看到这个HashMap是DefaultListableBeanFactory的singletonObjects字段可以证实FooService是Spring容器管理的单例的Bean。
你甚至可以在这个HashMap上点击右键选择Java Collections-&gt;Hash Entries功能来查看其内容
<img src="https://static001.geekbang.org/resource/image/ce/5f/ce4020b8f63db060a94fd039314b2d5f.png" alt="">
这样就列出了所有的Bean可以在Value上的Regex进一步过滤。输入FooService后可以看到类型为FooService的Bean只有一个其名字是fooService
<img src="https://static001.geekbang.org/resource/image/02/1a/023141fb717704cde9a57c5be6118d1a.png" alt="">
到现在为止我们虽然没看程序代码但是已经大概知道程序出现OOM的原因和大概的调用栈了。我们再贴出程序来对比一下果然和我们看到得一模一样
```
@SpringBootApplication
public class OOMApplication implements CommandLineRunner {
@Autowired
FooService fooService;
public static void main(String[] args) {
SpringApplication.run(OOMApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
//程序启动后不断调用Fooservice.oom()方法
while (true) {
fooService.oom();
}
}
}
@Component
public class FooService {
List&lt;String&gt; data = new ArrayList&lt;&gt;();
public void oom() {
//往同一个ArrayList中不断加入大小为10KB的字符串
data.add(IntStream.rangeClosed(1, 10_000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;)));
}
}
```
到这里我们使用MAT工具从对象清单、大对象、线程栈等视角分析了一个OOM程序的堆转储。可以发现有了堆转储几乎相当于拿到了应用程序的源码+当时那一刻的快照OOM的问题无从遁形。
## 使用Arthas分析高CPU问题
[Arthas](https://alibaba.github.io/arthas/)是阿里开源的Java诊断工具相比JDK内置的诊断工具要更人性化并且功能强大可以实现许多问题的一键定位而且可以一键反编译类查看源码甚至是直接进行生产代码热修复实现在一个工具内快速定位和修复问题的一站式服务。今天我就带你使用Arthas定位一个CPU使用高的问题系统学习下这个工具的使用。
首先下载并启动Arthas
```
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
java -jar arthas-boot.jar
```
启动后直接找到我们要排查的JVM进程然后可以看到Arthas附加进程成功
```
[INFO] arthas-boot version: 3.1.7
[INFO] Found existing java process, please choose one and hit RETURN.
* [1]: 12707
[2]: 30724 org.jetbrains.jps.cmdline.Launcher
[3]: 30725 org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication
[4]: 24312 sun.tools.jconsole.JConsole
[5]: 26328 org.jetbrains.jps.cmdline.Launcher
[6]: 24106 org.netbeans.lib.profiler.server.ProfilerServer
3
[INFO] arthas home: /Users/zhuye/.arthas/lib/3.1.7/arthas
[INFO] Try to attach process 30725
[INFO] Attach process 30725 success.
[INFO] arthas-client connect 127.0.0.1 3658
,---. ,------. ,--------.,--. ,--. ,---. ,---.
/ O \ | .--. ''--. .--'| '--' | / O \ ' .-'
| .-. || '--'.' | | | .--. || .-. |`. `-.
| | | || |\ \ | | | | | || | | |.-' |
`--' `--'`--' '--' `--' `--' `--'`--' `--'`-----'
wiki https://alibaba.github.io/arthas
tutorials https://alibaba.github.io/arthas/arthas-tutorials
version 3.1.7
pid 30725
time 2020-01-30 15:48:33
```
输出help命令可以看到所有支持的命令列表。今天我们会用到dashboard、thread、jad、watch、ognl命令来定位这个HighCPUApplication进程。你可以通过[官方文档](https://alibaba.github.io/arthas/commands.html),查看这些命令的完整介绍:
<img src="https://static001.geekbang.org/resource/image/47/73/47b2abc1c3a8c0670a60c6ed74761873.png" alt="">
dashboard命令用于整体展示进程所有线程、内存、GC等情况其输出如下
<img src="https://static001.geekbang.org/resource/image/ce/4c/ce59c22389ba95104531e46edd9afa4c.png" alt="">
可以看到CPU高并不是GC引起的占用CPU较多的线程有8个其中7个是ForkJoinPool.commonPool。学习过[加餐1](https://time.geekbang.org/column/article/212374)的话你应该就知道了ForkJoinPool.commonPool是并行流默认使用的线程池。所以此次CPU高的问题应该出现在某段并行流的代码上。
接下来要查看最繁忙的线程在执行的线程栈可以使用thread -n命令。这里我们查看下最忙的8个线程
```
thread -n 8
```
输出如下:
<img src="https://static001.geekbang.org/resource/image/96/00/96cca0708e211ea7f7de413d40c72c00.png" alt="">
可以看到由于这些线程都在处理MD5的操作所以占用了大量CPU资源。我们希望分析出代码中哪些逻辑可能会执行这个操作所以需要从方法栈上找出我们自己写的类并重点关注。
由于主线程也参与了ForkJoinPool的任务处理因此我们可以通过主线程的栈看到需要重点关注org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication类的doTask方法。
接下来使用jad命令直接对HighCPUApplication类反编译
```
jad org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication
```
可以看到调用路径是main-&gt;task()-&gt;doTask()当doTask方法接收到的int参数等于某个常量的时候会进行1万次的MD5操作这就是耗费CPU的来源。那么这个魔法值到底是多少呢
<img src="https://static001.geekbang.org/resource/image/45/e5/4594c58363316d8ff69178d7a341d5e5.png" alt="">
你可能想到了通过jad命令继续查看User类即可。这里因为是Demo所以我没有给出很复杂的逻辑。在业务逻辑很复杂的代码中判断逻辑不可能这么直白我们可能还需要分析出doTask的“慢”会慢在什么入参上。
这时我们可以使用watch命令来观察方法入参。如下命令表示需要监控耗时超过100毫秒的doTask方法的入参并且输出入参展开2层入参参数
```
watch org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication doTask '{params}' '#cost&gt;100' -x 2
```
可以看到所有耗时较久的doTask方法的入参都是0意味着User.ADMN_ID常量应该是0。
<img src="https://static001.geekbang.org/resource/image/04/3a/04e7a4e54c09052ab937f184ab31e03a.png" alt="">
最后我们使用ognl命令来运行一个表达式直接查询User类的ADMIN_ID静态字段来验证是不是这样得到的结果果然是0
```
[arthas@31126]$ ognl '@org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.User@ADMIN_ID'
@Integer[0]
```
需要额外说明的是由于monitor、trace、watch等命令是通过字节码增强技术来实现的会在指定类的方法中插入一些切面来实现数据统计和观测因此诊断结束要执行shutdown来还原类或方法字节码然后退出Arthas。
在这个案例中我们通过Arthas工具排查了高CPU的问题
- 首先通过dashboard + thread命令基本可以在几秒钟内一键定位问题找出消耗CPU最多的线程和方法栈
- 然后直接jad反编译相关代码来确认根因
- 此外如果调用入参不明确的话可以使用watch观察方法入参并根据方法执行时间来过滤慢请求的入参。
可见使用Arthas来定位生产问题根本用不着原始代码也用不着通过增加日志来帮助我们分析入参一个工具即可完成定位问题、分析问题的全套流程。
对于应用故障分析除了阿里Arthas之外还可以关注去哪儿的[Bistoury工具](https://github.com/qunarcorp/bistoury)其提供了可视化界面并且可以针对多台机器进行管理甚至提供了在线断点调试等功能模拟IDE的调试体验。
## 重点回顾
最后,我再和你分享一个案例吧。
有一次开发同学遇到一个OOM问题通过查监控、查日志、查调用链路排查了数小时也无法定位问题但我拿到堆转储文件后直接打开支配树图一眼就看到了可疑点。Mybatis每次查询都查询出了几百万条数据通过查看线程栈马上可以定位到出现Bug的方法名然后来到代码果然发现因为参数条件为null导致了全表查询整个定位过程不足5分钟。
从这个案例我们看到使用正确的工具、正确的方法来分析问题几乎可以在几分钟内定位到问题根因。今天我和你介绍的MAT正是分析Java堆内存问题的利器而Arthas是快速定位分析Java程序生产Bug的利器。利用好这两个工具就可以帮助我们在分钟级定位生产故障。
## 思考与讨论
1. 在介绍[线程池](https://time.geekbang.org/column/article/210337)的时候我们模拟了两种可能的OOM情况一种是使用Executors.newFixedThreadPool一种是使用Executors.newCachedThreadPool你能回忆起OOM的原因吗假设并不知道OOM的原因拿到了这两种OOM后的堆转储你能否尝试使用MAT分析堆转储来定位问题呢
1. Arthas还有一个强大的热修复功能。比如遇到高CPU问题时我们定位出是管理员用户会执行很多次MD5消耗大量CPU资源。这时我们可以直接在服务器上进行热修复步骤是jad命令反编译代码-&gt;使用文本编辑器比如Vim直接修改代码-&gt;使用sc命令查找代码所在类的ClassLoader-&gt;使用redefine命令热更新代码。你可以尝试使用这个流程直接修复程序注释doTask方法中的相关代码
在平时工作中你还会使用什么工具来分析排查Java应用程序的问题呢我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="36 | 加餐6这15年来我是如何在工作中学习技术和英语的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/0c/7045230e84b0712f43b28fbbe2c3b30c.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊如何在工作中,让自己成长得更快。
工作这些年来,经常会有同学来找我沟通学习和成长,他们的问题可以归结为两个。
一是长期参与CRUD业务开发项目技术提升出现瓶颈学不到新知识完全没有办法实践各种新技术以后会不会被淘汰、找不到工作
二是,英语学得比较晚,大学的时候也只是为了应试,英语水平很低,看不了英文的技术资料,更别说去外企找工作了。
不知道你是不是也面临这两个问题呢?今天,我就通过自己的经历和你分享一下,如何利用有限的环境、有限的时间来学习技术和英语?
## 学好技术
在我看来,知识网络的搭建就是在造楼房:基础也就是地基的承载力,决定了你能把楼造多高;广度就像是把房子造大、造宽;深度就是楼房的高度。因此,如果你想要提升自己的水平,那这三个方面的发展缺一不可。
### 第一,学习必须靠自觉。
虽说工作经历和项目经验是实践技术、提升技术的一个重要手段,但不可能所有的工作经历和项目都能持续地提升我们的技术。所以,我们要想提升自己的技术水平,就必须打消仅仅通过工作经历来提升的念头,要靠业余时间主动地持续学习和积累来提升。
比如你可以针对项目中用到的技术全面阅读官方文档做各种Demo来论证其技术特性。在这个过程中你一定还会产生许多技术疑问那就继续展开学习。
### 第二,不要吝啬分享。
刚毕业那会我花了很多时间和精力在CSDN回答问题积极写博客、写书和翻译书。这些经历对我的技术成长帮助非常大。
很多知识点我们自认为完全掌握了但其实并不是那么回事儿。当我们要说出来教别人的时候就必须100%了解每一个细节。因此,分享不仅是帮助自己进一步理清每一个知识点、锻炼自己的表达能力,更是一种强迫自己学习的手段,因为你要保证按时交付。
当然了分享的过程也需要些正向激励让自己保持分享的激情。就比如说我获得的几次微软MVP、CSDN TOP3专家等荣誉就对我激励很大可以让我保持热情去不断地学习并帮助别人。
### 第三,不要停留在舒适区。
分享一段我的真实经历吧。我加入一家公司组建新团队后,在做技术选型的时候,考虑到成本等因素,放弃了从事了七年的.NET技术转型Java。有了.NET的积累我自己转型Java只用了两周。其实一开始做这个决定非常痛苦但是突破自己的舒适区并没有想象得那么困难。随后我又自学了iOS、深度学习、Python等技术或语言。
随着掌握的技术越来越多这些技术不但让我触类旁通更让我理解了技术只是工具解决问题需要使用合适的技术。因此我也建议你利用业余时间多学习几门不同类型的编程语言比如Java、Python和Go。
有些时候,我们因为恐惧跳出舒适区而不愿意学习和引入合适的新技术来解决问题,虽然省去了前期的学习转型成本,但是后期却会投入更多的时间来弥补技术上的短板。
### 第四,打好基础很重要。
这里的“基础”是指和编程语言无关的那部分知识包括硬件基础、操作系统原理、TCP/IP、HTTP、数据结构和算法、安全基础、设计模式、数据库原理等。学习基础知识是比较枯燥的过程需要大块的时间来系统阅读相关书籍并要尝试把学到的知识付诸实践。只有实践过的技术才能映入脑子里否则只是书本上的知识。
比如学习TCP/IP的时候我们可以使用Wireshark来观察网络数据。又比如学习设计模式的时候我们可以结合身边的业务案例来思考下是否有对应的适用场景如果没有能否模拟一个场景然后使用所有设计模式和自己熟悉的语言开发一个实际的Demo。
这些看似和我们日常业务开发关系不大的基础知识,是我们是否能够深入理解技术的最重要的基石。
### 第五,想办法积累技术深度。
对开发者而言技术深度体现在从一个框架、组件或SDK的使用者转变为开发者。
虽然不建议大家重复去造轮子、造框架但我们完全可以阅读各种框架的源码去了解其实现并亲手实现一些框架的原型。比如你可以尝试把MVC、RPC、ORM、IoC、AOP等框架都实现一个最基本功能点原型。在实现的过程中你一定会遇到很多问题和难点然后再继续研究一下Spring、Hibernate、Dubbo等框架是如何实现的。
当把自己的小框架实现出来的那一刻,你收获的不仅是满满的成就感,更是在技术深度积累上的更进一步。在这个过程中,你肯定会遇到不少问题、解决不少问题。有了这些积累,之后再去学习甚至二次开发那些流行的开源框架,就会更容易了。
除了实现一些框架外我还建议你选择一个中间件比如Redis、RocketMQ来练手学习网络知识。
我们可以先实现它的客户端用Netty实现TCP通信层的功能之后参照官方文档实现协议封装、客户端连接池等功能。在实现的过程中你可以对自己实现的客户端进行压测分析和官方实现的性能差距。这样一来你不仅可以对TCP/IP网络有更深入的了解还可以获得很多网络方面的优化经验。
然后,再尝试实现服务端,进一步加深对网络的认识。最后,尝试把服务端扩展为支持高可用的集群,来加深对分布式通信技术的理解。
在实现这样一个分布式C/S中间件的过程中你对技术的理解肯定会深入了许多。在这个过程中你会发现技术深度的“下探”和基础知识的积累息息相关。基础知识不扎实往深了走往往会步履维艰。这时你可以再回过头来重新系统学习一些基础理论。
### 第六,扩大技术广度也重要。
除了之前提到的多学几门编程语言之外,在技术广度的拓展上,我们还可以在两个方面下功夫。
第一阅读大量的技术书籍。新出来的各种技术图书不只是编程相关的一般我都会买。十几年来我买了500多本技术图书大概有三分之一是完整看过的还有三分之一只翻了一个大概还有三分之一只看了目录。
广泛的阅读,让我能够了解目前各种技术的主流框架和平台。这样的好处是,在整体看技术方案的时候,我可以知道大家都在做什么,不至于只能理解方案中的一部分。对于看不完的、又比较有价值的书,我会做好标签,等空闲的时候再看。
第二在开发程序的时候我们会直接使用运维搭建的数据库比如Elasticsearch、MySQL、中间件比如RabbitMQ、ZooKeeper、容器云比如Kubernetes。但如果我们只会使用这些组件而不会搭建的话对它们的理解很可能只是停留在API或客户端层面。
因此,我建议你去尝试下从头搭建和配置这些组件,在遇到性能问题的时候自己着手分析一下。把实现技术的前后打通,遇到问题时我们就不至于手足无措了。我通常会购买公有云按小时收费的服务器,来构建一些服务器集群,尝试搭建和测试这些系统,加深对运维的理解。
## 学好英语
为啥要单独说英语的学习方法呢,这是因为学好英语对做技术的同学非常重要:
- 国外的社区环境比较好许多技术问题只有通过英文关键字才能在Google或Stackoverflow上搜到答案
- 可以第一时间学习各种新技术、阅读第一手资料,中文翻译资料往往至少有半年左右的延迟;
- 参与或研究各种开源项目,和老外沟通需要使用英语来提问,以及阅读别人的答复。
所以说,学好英语可以整体拓宽个人视野。不过,对于上班族来说,我们可能没有太多的大块时间投入英语学习,那如何利用碎片时间、相对休闲地学习英语呢?还有一个问题是,学好英语需要大量的练习和训练,但不在外企工作就连个英语环境都没有,那如何解决这样的矛盾呢?
接下来,我将从读、听、写和说四个方面,和你分享一下我学习英语的方法。
### 读方面
读对于我们这些搞技术的人来说是最重要的,并且也是最容易掌握的。我建议你这么学:
- 先从阅读身边的技术文档开始,有英语文档的一定要选择阅读英语文档。一来,贴近实际工作,是我们真正用得到的知识,比较容易有兴趣去读;二来,这些文档中大部分词汇,我们日常基本都接触过,难度不会太大。
- 技术书籍的常用词汇量不大,有了一些基础后,你可以正式或非正式地参与翻译一些英语书籍或文档。从我的经验来看,翻译过一本书之后,你在日常阅读任何技术资料时基本都不需要查字典了。
- 订阅一些英语报纸比如ChinaDaily。第一贴近日常生活都是我们身边发生的事儿不会很枯燥第二可以进一步积累词汇量。在这个过程中你肯定需要大量查字典打断阅读让你感觉很痛苦。但一般来说一个单词最多查三次也就记住了所以随着时间推移你慢慢可以摆脱字典词汇量也可以上一个台阶了。
技术方面阅读能力的培养,通常只需要三个月左右的时间,但生活方面资料的阅读可能需要一年甚至更长的时间。
### 听方面
读需要积累词汇量,听力的训练需要通过时间来磨耳朵。每个人都可以选择适合自己的材料来磨耳朵,比如我是通过看美剧来训练听力的。
我就以看美剧为例,说说练听力的几个关键点。
- 量变到质变的过程需要1000小时的量。如果一部美剧是100小时那么看前9部的时候可能都很痛苦直到某一天你突然觉得一下子都可以听懂了。
- 需要确保看美剧时没有中文字幕,否则很难忍住不看,看了字幕就无法起到训练听力的效果。
- 在美剧的选择上可以先选择对话比较少也可以选择自己感兴趣的题材这样不容易放弃。如果第一次听下来听懂率低于30%,连理解剧情都困难,那么可以先带着中文字幕看一遍,然后再脱离字幕看。
- 看美剧不在乎看的多少而是要找适合的素材反复训练。有人说反复看100遍《老友记》英语的听说能力可以接近母语是英语的人的水平。
如果看美剧不适合你的话你可以选择其他方式比如开车或坐地铁的时候听一些感兴趣的PodCast等。
总而言之选择自己喜欢的材料和内容从简单开始不断听。如果你有一定词汇量的话查字典其实不是必须的很多时候不借助字典同一个单词出现10遍后我们也就知道它的意思了。
一定要记住在积累1000小时之前别轻易放弃。
### 写方面
如果有外企经历,那么平时写英语邮件和文档基本就可以让你的工作英语过关;如果没有外企经历也没关系,你可以尝试通过下面的方式锻炼写作:
- 每天写英语日记。日记是自己看的,没人会嘲笑你,可以从简单的开始。
- 在保持写作的同时,需要确保自己能有持续的一定量的阅读。因为,写作要实现从正确到准确到优雅,离不开阅读的积累。
- 写程序的时候使用英语注释,或者尝试写英语博客,总之利用好一切写的机会,来提升自己的英语表达。
再和你分享一个小技巧。当你要通过查词典知道中文的英语翻译时,尽量不要直接用找到的英文单词,最好先在英语例句中确认这个翻译的准确性再使用,以免闹笑话。
### 说方面
训练说英语的机会是最少的,毕竟身边说英语的人少,很难自己主动练习。
这里我和你分享两个方法吧。
第一是买外教的1-1对话课程来训练。这些课程一般按小时计费由母语是英语的人在线和你聊一些话题帮助你训练对话能力。
买不买课程不重要,只要能有母语是英语的人来帮你提升就可以。同时,大量的听力训练也可以帮助你提升说的能力,很多英语短句经过反复强化会成为脱口而出的下意识反应。所以,你会发现在听力达到质变的时候,说的能力也会上一个台阶。
第二,大胆说,不要担心有语法错误、单词发音问题、表达不流畅问题而被嘲笑。其实,你可以反过来想想,老外说中文时出现这些问题,你会嘲笑他吗。
这里有一个技巧是,尽量选用简单的表达和词汇去说,先尝试把内容说出来,甚至是只说几个关键字,而不是憋着在脑子里尝试整理一个完整的句子。灵活运用有限的单词,尽可能地流畅、准确表达,才是聪明的做法。
## 总结
最后我想说如果你感觉学得很累、进步很慢也不要放弃坚持下来就会越来越好。我刚毕业那会儿有一阵子也对OOP很迷茫感觉根本无法理解OOP的理念写出的代码完全是过程化的代码。但我没有放弃参与写了几年的复杂业务程序再加上系统自学设计模式到某一个时刻我突然就能写出OOP的业务代码了。
学习一定是一个日积月累、量变到质变的过程,希望我分享的学习方法能对你有启发。不过,每个人的情况都不同,一定要找到适合自己的学习方式,才更容易坚持下去。
持续学习很重要,不一定要短时间突击学习,而最好是慢慢学、持续积累,积累越多学习就会越轻松。如果学习遇到瓶颈感觉怎么都学不会,也不要沮丧,这其实还是因为积累不够。你一定也有过这样的经验:一本去年觉得很难啃的书,到今年再看会觉得恰到好处,明年就会觉得比较简单,就是这个道理。
我是朱晔,欢迎在评论区与我留言分享你学习技术和英语的心得,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,236 @@
你好,我是朱晔。
今天直播我准备和你聊聊程序员成长的话题。我从毕业后入行到现在作为一个高级管理者已经在互联网领域拼搏了15年了。我把这些年自己的成长历程、心得体会整理成了“程序员成长28计”。
今天我就和你分别聊聊这28计。
## 入门 0.5年
**第1计**:不要过于纠结方向选择问题。
开始入门的时候我们可能都会纠结于选择前端还是后端选择了后端还犹豫到底选Java、Go还是Python。
其实,我觉得不用过于纠结。如果说你对偏前端的内容感兴趣,那就从前端入手;对数据库方面的内容感兴趣,那就从后端入手。等你真正入门以后,你再去转方向、转技术栈都会非常容易,因为技术都是相通的。
**第2计**学习一定要敢于踏出真正的第一步。
这里我说的第一步不是说开始看某个领域的书了而是真正把IDE下载好、把编程环境搭建好并实现一个最简单的程序。我一直觉得把编程环境搭建好就已经算是入门一半了。
如果你只是停留在看书这个层次上的话,是永远入不了门的。因为这些知识只是停留在书上,还没有真正变成你自己的。只有自己写过、实践过,才能真正掌握。
**第3计**找人给你指一下方向。
刚入门的时候面对各种各样的语言、技术你很可能会迷茫。就比如说刚入门后端的时候Spring全家桶有十几样还有各种数据库方面的Java程序本身的语法和框架那到底先学什么呢这个时候只要找人给你指点一下学习的顺序以及按照怎样的主线来学习就会事半功倍。否则你会在大量的资料里花费大量的时间、消耗大量的精力。
**第4计**:找准合适的入门资料。
在我看来,选择入门资料,需要注意两点:
1. 一定要选择手把手的资料,也就是从搭环境开始怎么一步步地去操作,并带一些实战项目。这样看,视频课程可能更合适。
1. 要难度合适。
那怎么理解“难度合适”呢举个例子你看的这本书的知识深度在70分而你自己的知识深度在60分那这本书非常合适。因为从60到65的感觉是非常爽的。在60分的时候你有能力去汲取70分深度的书里面的知识点然后你会变成65分。而如果你现在的知识深度在20分去看70分的书或者你的知识深度在75分却去看70分的书就不会有任何感觉、任何收益。所以很多同学看我的专栏课程会有共鸣也是这个原因。
## 程序员2年
**第5计**:想办法系统性地学习。
步入两年这个阶段后,我们要开始想办法系统性地学习了,比如系统性地学习设计模式、算法、数据库。只有系统性地学习,才能给我们建立起完整的知识框架。因为一定是先有知识网络,才能在网络上继续铺更多的东西。
那怎么才能有系统性学习的动力呢?
第一,分享可以让自己有动力。比如,你说要写一个什么系列的文章,那话说出去了,就会逼着自己去实现。
第二,花钱买课程,做系统性的学习。当你花了几百甚至几千块钱去买课程的时候,就会逼着自己的学习,不然钱就浪费掉了。
**第6计**选择一份好工作。
选择一份好工作,也就是选择一个好的项目,从而积累一些人脉资源,是非常重要的,可能要比技术成长更重要些。
比如说,你能够进入到一个相对较大的公司,它能带给你的最最主要的就是人脉资源,也就是你能够认识更多、更优秀的人。认识这些人,就是你日后的机会。
**第7计**学习必须靠自觉。
我们不能期望项目经验一定或者说一直会给自己带来技术提升。即使是你能接触一些高并发的、比较复杂的项目,它们带来的提升也是有限的,或者说持续的时间通常会比较短。
因为大多数公司在乎的都是你的输出,输出你的能力和经验。所以说,学习和成长这件事儿,必须靠自觉,包括自觉地去想如何系统性地学习、如何有计划地学习,以及平时要多问为什么。
**第8计**看适合自己的书。
这里也是说,我们在看书的过程中,要注意去鉴别书的层次,选择难度合适的书。
其实,在做程序员前两年的时间里,我不太建议去广泛地看书,要先想办法能够专注些,打好自己主要的编程语言的基础;然后,围绕着自己主要的编程语言或者主要使用的技术去看书。
**第9计**想办法积累技术广度。
将来踏上技术管理路线之后,你有可能管的团队不是你这个领域,比如你是后端出身可能要带领移动团队。如果你不知道移动端最基本的东西的话,是没有办法跟团队成员沟通的。所以说,你可以有自己的一个专长,但是你要知道其他领域最基本的东西。
积累技术广度的方式,主要有下面三种。
第一,体验全栈。如果你是做后端的,就应该去大概了解下客户端、移动端,或者说大前端;可以了解下测试和运维怎么做,了解运维的话帮助可能会更大。你还可以动手做一个自己的项目,就从云服务器的采购开始。在搭建项目部署的过程中,你可以自己去搭建运维相关的部分,甚至是自己搭建一些中间件。
因为在大厂,一般都有自动化发布系统、有工程化平台、有自己的运维体系、有自己的监控系统等等。但是,如果只是使用这些工具的话,我们是没法建立一个全局观的,因为我们不知道它们是怎么运作的。
第二,多学一些编程语言。但是学了几门编程语言后,你会发现每门语言都有自己的特色和软肋。这就会引发你很多的思考,比如为什么这个语言没有这个特性,又怎么样去解决。另外,每门语言他都有自己的技术栈,你会来回地比较。这些思考和比较,对自己的成长都很有用。
如果你对一个语言的掌握比较透彻的话,再去学其他语言不会花很久。我刚毕业是做.net后来转了Java再后来又去学Python。因为高级语言的特性基本上都差不多你只要学一些语法用到的时候再去查更多的内容然后做个项目所以学一门语言可能也就需要一个月甚至会更快一些。
第三,广泛看书。
**第10计**想办法积累技术深度。
主要的方式是造轮子、看源码和学底层。
第一,造轮子。所谓的造轮子,不一定是要造完要用,你可以拿造轮子来练手,比如徒手写一个框架。在这个过程中,你会遇到很多困难,然后可能会想办法去学习一些现有技术的源码,这对技术深度的理解是非常有帮助的。
第二,看一些源码。如果你能够理清楚一些源码的主线,然后你能积累很多设计模式的知识。
第三,学一些偏向于底层的东西,可以帮助你理解技术的本质。上层的技术都依赖于底层的技术,所以你学完了底层的技术后,就会发现上层的技术再变也没有什么本质上的区别,然后学起来就会非常快。
**第11计**学会使用搜索引擎。
对于程序员来说最好可以使用Google来搜索也就是说要使用英文的关键字来搜索。一方面通过Google你可以搜到更多的内容另一方面国外的技术圈或者网站关于纯技术的讨论会多一些。
**第12计**学会和适应画图、写文档。
我觉得写文档是在锻炼自己的总结能力和表达能力画图更多的是在锻炼自己的抽象能力。写文档、画架构图不仅仅是架构师需要具备的能力还是你准确表达自己观点的必备方式。所以我们不要觉得宁肯写100行代码也不愿意写一句话。
## 架构师3年
**第13计**注意软素质的提升。
这时候你已经有了好几年的经验了,那除了技术方面,还要注意软素质,比如沟通、自我驱动、总结等能力的提升。比如说沟通能力,就是你能不能很流畅地表达自己的观点,能不能比较主动地去沟通。
这些素质在日常工作中还是挺重要的,因为你做了架构师之后,免不了要去跟业务方和技术团队,甚至是其他的团队的架构师去沟通。 如果你的这些软素质不过硬,那可能你的方案就得不到认可,没办法达成自己的目标。
**第14计**积累领域经验也很重要。
当你在一个领域工作几年之后,你就会对这个领域的产品非常熟悉,甚至比产品经理更懂产品。也就是说,即使这个产品没有别人的帮助,你也可以确保它朝着正确的方向发展。如果你想一直在这个领域工作的话,这种领域经验的积累就对自己的发展非常有帮助。
所以说,有些人做的是业务架构师,他可能在技术上并不是特别擅长,但对这个领域的系统设计或者说产品设计特别在行。如果说,你不想纯做技术的话,可以考虑积累更多的领域经验。
**第15计**架构工作要接地气。
我以前做架构师的时候发现,有些架构师给出的方案非常漂亮,但就是不接地气、很难去落地。所以,在我看来,架构工作必须要接地气,包括三个方面:产出符合实际情况的方案、方案要落地实际项目、不要太技术化。
这里其实会有一个矛盾点:如果你想要提升自己的经验、技术,很多时候就需要去引入一些新技术,但是这些新技术的引入需要成本。而这里的成本不仅仅是你自己学习的成本,还需要整个团队有一定的经验。
比如Kubernetes不是你引入了团队用就完事儿整个团队的技术都需要得到提升才能够驾驭这个系统。如果我们是为了自己的利益去引入一些不太符合公司实际情况的技术的话其实对公司来说是不负责任的而且这个方案很大程度上有可能会失败。
所以说,我觉得做架构工作是要产出一些更接地气的方案。比如同样是解决一个问题,有些架构方式或设计比较“老土”,但往往是很稳定的;而一些复杂的技术,虽然有先进的理念和设计,但你要驾驭它就需要很多成本,而且因为它的“新”往往还会存在各种各样的问题。
这也就是说,我们在设计架构的时候,必须要权衡方案是否接地气。
**第16计**打造个人品牌。
我觉得,个人品牌包括口碑和影响力两个方面。
口碑就是你日常工作的态度,包括你的能力和沟通,会让人知道你靠不靠谱、能力是不是够强。好的口碑再加上宝贵的人脉,就是你非常重要的资源。口碑好的人基本上是不需要主动去找工作的,因为一直会有一些老领导或者朋友、同事会千方百计地想要给你机会。
很多人的技术非常不错,但就是没人知道他,问题就出在影响力上。而提升影响力的方法,无外乎就是参加技术大会、做分享、写博客、写书等等。
有了影响力和口碑,让更多的人能接触到你、认识你,你就会有更多的机会。
## 技术管理
**第17计**掌握管事的方法。
“管事”就是你怎样去安排,这里包括了制定项目管理流程、制定技术标准、工具化和自动化三个方面。
刚转做技术管理时容易犯的一个错的是,把事情都抓在自己手里。这时,你一定要想通,不是你自己在干活,你的产出是靠团队的。与其说什么事情都自己干,还不如说你去制定规范、流程和方向,然后让团队去做,否则你很容易就成了整个团队的瓶颈。
**第18计**掌握带团队的方法。
第一,招人&amp;放权。带团队的话,最重要是招到优秀的人,然后就是放权。不要因为担心招到的人会比自己优秀,就想要找“弱”一些的。只有团队的事情做得更好了,你的整个团队的产出才是最高。
第二,工程师文化。通过建立工程师文化,让大家去互相交流、学习,从而建立一个良好的学习工作氛围。
第三,适当的沟通汇报制度。这也属于制定流程里面的,也是要建立一个沟通汇报的制度。
**第19计**关注前沿技术,思考技术创新。
做了技术管理之后,你的视角要更高。你团队的成员,可能只是看到、接触到这一个部分、这一个模块,没有更多的信息,也没办法想得更远。这时,你就必须去创新、去关注更多的前沿技术,去思考自己的项目能不能用上这些技术。
**第20计**关注产品。
在我看来,一个产品的形态很多时候决定了公司的命运,在产品上多想一些点子,往往要比技术上的重构带来的收益更大。这里不仅仅包括这个产品是怎么运作的,还包括产品中包含的创新、你能否挖掘一些衍生品。
## 高级技术管理
在这个层次上面我们更高级的技术管理可能是总监级别甚至以上我以前在两家百人以上的小公司做过CTO。我当时的感觉是所做的事情不能仅限于产品技术本身了。
**第21计**搭建团队最重要。
这和招人还不太一样,招人肯定招的是下属,而搭建团队是必须让团队有一个梯队。一旦你把一些核心的人固化下来以后,整个团队就发展起来了。所以,你要在招人方面花费更多的精力,当然不仅仅是指面试。
搭建团队最重要的是你自己要有一个想法,知道自己需要一个什么样的职位来填补空缺,这个岗位上又需要什么样的人。
**第22计**打造技术文化。
虽然在做技术管理的时候,我强调说要建立制度,但文化会更高于制度,而且文化没有那么强势。因为制度其实是列出来,要求大家去遵守,有“强迫”的感觉;而文化更强调潜移默化,通过耳濡目染获得大同感。这样一来,大家慢慢地就不会觉得这是文化了,而是说我现在就是这么干事儿的。
**第23计**提炼价值观。
价值观是说公司按照这个理念去运作,希望有一些志同道合的人在一起干活。所以价值观又会高于文化,是整个公司层面的,对大家的影响也会更多。
虽然说价值观不会那么显性,但可以长久地确保公司里面的整个团队的心都是齐的,大家都知道公司是怎么运作的,有相同的目标。
**第24计**关注运营和财务。
到了高级技术管理的位置,你就不仅仅是一个打工的了,你的命运是和公司紧紧绑定在一起的。所以,你需要更多地关注公司的运营和财务。
当你觉得自己的团队很小却要做那么多项目的时候,可以站在更高的角度去换位思考下。这时你可能就发现,你的团队做的事情并没有那么重要,对整个公司的发展来说你的团队规模已经足够了。如果说我们再大量招人的话,那么财务上就会入不敷出,整个公司的情况肯定也不会好。
## 职场心得
**第25计**掌握工作汇报的方式方法。
首先,我们不要把汇报当作负担、当作浪费时间。汇报其实是双向的,你跟上级多沟通的话,他可以反馈给你更多的信息,这个信息可能是你工作的方向,也可能是给你的一些资源,还可能是告诉你上级想要什么。因为你和你的上级其实在一个信息层面上是不对等的,他能收到更上级的信息,比如公司策略方面的信息。
**第26计**坚持+信念。
第一,如果说你的目标就是成功的话,那没有什么可以阻挡你。职场上的扯皮和甩锅,都是避免不了的。举个例子吧。
我以前在一家公司工作的时候,别人不愿意配合我的工作。那怎么办呢,我知道自己的目标是把这件事儿做成。当时,这个项目的很多内容,比如说运维,都不在我这边,需要其他同事来负责。但人家就是不配合,群里艾特也不看,打电话也不接,那我怎么办呢?多打两次呗,实在不行我就发邮件抄送大家的上级。总之,就是想尽办法去沟通,因为你的目标就是成功。
第二,很多时候,创新就是相信一定可以实现才有的。
很多时候,你觉得这个事情是做不成的,然后直接拒绝掉了,创新就没有了。但如果相信这个事情一定是可以做成的,你就会想方设法去实现它,这个时候你想出来的东西就是有开创性的,就是创新。
**第27计**持续的思考和总结。
在职场上提炼方法论是非常重要的。你要去思考自己在工作中对各种各样的事情的处理,是不是妥当,是不是能够总结出一些方法论。把这些方法论提炼保留下来,将来是能够帮到你的。很多东西,比如复盘自己的工作经历、复盘自己的选择,都要动脑子、都要去写,不能说过去了就过去了。这些经历提炼出的方法论,都是你的经验,是非常有价值的。
**第28计**有关和平级同事的相处。
和平级同事之间,要以帮助别人的心态来合作。我们和上下级的同事来沟通,一般是不会有什么问题的,但跟平级的,尤其是跨部门的平级同事去沟通的时候,往往会因为利益问题,不会很愉快。
我觉得,这里最重要的就是以帮助别人的心态来合作。 比如这样说“你有什么困难的话,可以来问我”“你人手是不是不够,我可以帮你一起把这个项目做好”。这样大家的合作会比较顺畅,别人也不会有那么多戒心。
人和人的沟通,还在于有一层纱,突破了这层纱以后,大家就都会相信你,觉得你是一个靠谱的人。这样,平级同事也会愿意和你分享一些东西,因为他放心。
## 管理格言
接下来我要推荐的8条管理格言是曹操管理和用人的理念不是我自己总结出来的。
第一,真心诚意,以情感人。人和人之间去沟通的时候,不管是和上级或者下级的沟通,都要以非常诚恳的态度去沟通。
第二,推心置腹,以诚待人。有事情不要藏在心里,做“城府很深”的管理者。我觉得更好的方式是,让大家尽可能地知道更多的事儿,统一战线,站在一个角度来考虑问题。
第三,开诚布公,以理服人。把管理策略公布出来,不管是奖励也好惩罚也罢,让团队成员感觉公平公正,
第四,言行一致,以信取人。说到做到,对于管理下属、和别人沟通都非常重要。
第五,令行禁止,依法治人。管理上,你要制定好相关的制度,而且要公开出来。如果触犯了制度就需要惩罚,做得好了就要有奖赏。
第六,设身处地,以宽容人。很多时候,我们和别人的矛盾是没有足够的换位思考,没有设身处地地去想。如果说你的下属犯了错,还是要想一想是不是多给些机会,是不是能宽容一些。
第七,扬人责己,以功归人。这是非常重要的一点。事情是团队一起做的话,那就是团队的功劳,甚至下属的功劳。如果别人做得好的话,就要多表扬一些。对自己要严格一些,很多时候团队的问题就是管理者的问题,跟下属没太多关系。
第八,论功行赏,以奖励人。做得好了,要多给别人一些奖励。这也是公平公正的,大家都能看得到。
最后我将关于程序员成长的28计整理在了一张思维导图上以方便你收藏、转发。<br>
<img src="https://static001.geekbang.org/resource/image/b7/bf/b72104acfeeeecef49ab6c0a5908cebf.jpg" alt="">
我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,272 @@
<audio id="audio" title="答疑篇:加餐篇思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/db/8d3847da3b36e5c590f376694eda7bdb.mp3"></audio>
你好,我是朱晔。
今天我们继续一起分析这门课的“不定期加餐”篇中5讲的课后思考题。这些题目涉及了Java 8基础知识、定位和分析应用问题相关的几大知识点。
接下来,我们就一一具体分析吧。
### [加餐1 | 带你吃透课程中Java 8的那些重要知识点](https://time.geekbang.org/column/article/212374)
**问题:**对于并行流部分的并行消费处理1到100的例子如果把forEach替换为forEachOrdered你觉得会发生什么呢
forEachOrdered 会让parallelStream丧失部分的并行能力主要原因是forEach遍历的逻辑无法并行起来需要按照循序遍历无法并行
我们来比较下面的三种写法:
```
//模拟消息数据需要1秒时间
private static void consume(int i) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(i);
}
//模拟过滤数据需要1秒时间
private static boolean filter(int i) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return i % 2 == 0;
}
@Test
public void test() {
System.setProperty(&quot;java.util.concurrent.ForkJoinPool.common.parallelism&quot;, String.valueOf(10));
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;stream&quot;);
stream();
stopWatch.stop();
stopWatch.start(&quot;parallelStream&quot;);
parallelStream();
stopWatch.stop();
stopWatch.start(&quot;parallelStreamForEachOrdered&quot;);
parallelStreamForEachOrdered();
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
}
//filtre和forEach串行
private void stream() {
IntStream.rangeClosed(1, 10)
.filter(ForEachOrderedTest::filter)
.forEach(ForEachOrderedTest::consume);
}
//filter和forEach并行
private void parallelStream() {
IntStream.rangeClosed(1, 10).parallel()
.filter(ForEachOrderedTest::filter)
.forEach(ForEachOrderedTest::consume);
}
//filter并行而forEach串行
private void parallelStreamForEachOrdered() {
IntStream.rangeClosed(1, 10).parallel()
.filter(ForEachOrderedTest::filter)
.forEachOrdered(ForEachOrderedTest::consume);
}
```
得到输出:
```
---------------------------------------------
ns % Task name
---------------------------------------------
15119607359 065% stream
2011398298 009% parallelStream
6033800802 026% parallelStreamForEachOrdered
```
从输出中,我们可以看到:
- stream方法的过滤和遍历全部串行执行总时间是10秒+5秒=15秒
- parallelStream方法的过滤和遍历全部并行执行总时间是1秒+1秒=2秒
- parallelStreamForEachOrdered方法的过滤并行执行遍历串行执行总时间是1秒+5秒=6秒。
### [加餐2 | 带你吃透课程中Java 8的那些重要知识点](https://time.geekbang.org/column/article/212398)
**问题1**使用Stream可以非常方便地对List做各种操作那有没有什么办法可以实现在整个过程中观察数据变化呢比如我们进行filter+map操作如何观察filter后map的原始数据呢
要想观察使用Stream对List的各种操作的过程中的数据变化主要有下面两个办法。
第一,**使用peek方法**。比如如下代码我们对数字1~10进行了两次过滤分别是找出大于5的数字和找出偶数我们通过peek方法把两次过滤操作之前的原始数据保存了下来
```
List&lt;Integer&gt; firstPeek = new ArrayList&lt;&gt;();
List&lt;Integer&gt; secondPeek = new ArrayList&lt;&gt;();
List&lt;Integer&gt; result = IntStream.rangeClosed(1, 10)
.boxed()
.peek(i -&gt; firstPeek.add(i))
.filter(i -&gt; i &gt; 5)
.peek(i -&gt; secondPeek.add(i))
.filter(i -&gt; i % 2 == 0)
.collect(Collectors.toList());
System.out.println(&quot;firstPeek&quot; + firstPeek);
System.out.println(&quot;secondPeek&quot; + secondPeek);
System.out.println(&quot;result&quot; + result);
```
最后得到输出可以看到第一次过滤之前是数字1~10一次过滤后变为6~10最终输出6、8、10三个数字
```
firstPeek[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
secondPeek[6, 7, 8, 9, 10]
result[6, 8, 10]
```
第二,**借助IDEA的Stream的调试功能**。详见[这里](https://www.jetbrains.com/help/idea/analyze-java-stream-operations.html),效果类似下图:
<img src="https://static001.geekbang.org/resource/image/3e/5e/3ee49c0589286bba37dd66032530d65e.png" alt="">
**问题2**Collectors类提供了很多现成的收集器那我们有没有办法实现自定义的收集器呢比如实现一个MostPopularCollector来得到List中出现次数最多的元素满足下面两个测试用例
```
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector&lt;&gt;()).get(), is(2));
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector&lt;&gt;()).get(), is('c'));
```
我来说下我的实现思路和方式通过一个HashMap来保存元素的出现次数最后在收集的时候找出Map中出现次数最多的元素
```
public class MostPopularCollector&lt;T&gt; implements Collector&lt;T, Map&lt;T, Integer&gt;, Optional&lt;T&gt;&gt; {
//使用HashMap保存中间数据
@Override
public Supplier&lt;Map&lt;T, Integer&gt;&gt; supplier() {
return HashMap::new;
}
//每次累积数据则累加Value
@Override
public BiConsumer&lt;Map&lt;T, Integer&gt;, T&gt; accumulator() {
return (acc, elem) -&gt; acc.merge(elem, 1, (old, value) -&gt; old + value);
}
//合并多个Map就是合并其Value
@Override
public BinaryOperator&lt;Map&lt;T, Integer&gt;&gt; combiner() {
return (a, b) -&gt; Stream.concat(a.entrySet().stream(), b.entrySet().stream())
.collect(Collectors.groupingBy(Map.Entry::getKey, summingInt(Map.Entry::getValue)));
}
//找出Map中Value最大的Key
@Override
public Function&lt;Map&lt;T, Integer&gt;, Optional&lt;T&gt;&gt; finisher() {
return (acc) -&gt; acc.entrySet().stream()
.reduce(BinaryOperator.maxBy(Map.Entry.comparingByValue()))
.map(Map.Entry::getKey);
}
@Override
public Set&lt;Characteristics&gt; characteristics() {
return Collections.emptySet();
}
}
```
### [加餐3 | 定位应用问题,排错套路很重要](https://time.geekbang.org/column/article/221982)
**问题:**如果你现在打开一个App后发现首页展示了一片空白那这到底是客户端兼容性的问题还是服务端的问题呢如果是服务端的问题又如何进一步细化定位呢你有什么分析思路吗
答:首先,我们需要区分客户端还是服务端错误。我们可以先从客户端下手,排查看看是否是服务端问题,也就是通过抓包来看服务端的返回(一般而言客户端发布之前会经过测试,而且无法随时变更,所以服务端出错的可能性会更大一点)。因为一个客户端程序可能对应几百个服务端接口,先从客户端(发出请求的根源)开始排查问题,更容易找到方向。
服务端没有返回正确的输出,那么就需要继续排查服务端接口或是上层的负载均衡了,排查方式为:
- 查看负载均衡比如Nginx的日志
- 查看服务端日志;
- 查看服务端监控。
如果服务端返回了正确的输出那么要么是由于客户端的Bug要么就是外部配置等问题了排查方式为
- 查看客户端报错一般而言客户端都会对接SAAS的异常服务
- 直接本地启动客户端调试。
### [加餐4 | 分析定位Java问题一定要用好这些工具](https://time.geekbang.org/column/article/224816)
**问题1**JDK中还有一个jmap工具我们会使用jmap -dump命令来进行堆转储。那么这条命令和jmap -dump:live有什么区别呢你能否设计一个实验来证明下它们的区别呢
jmap -dump命令是转储堆中的所有对象而jmap -dump:live是转储堆中所有活着的对象。因为jmap -dump:live会触发一次FullGC。
写一个程序测试一下:
```
@SpringBootApplication
@Slf4j
public class JMapApplication implements CommandLineRunner {
//-Xmx512m -Xms512m
public static void main(String[] args) {
SpringApplication.run(JMapApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
while (true) {
//模拟产生字符串每次循环后这个字符串就会失去引用可以GC
String payload = IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -&gt; &quot;a&quot;)
.collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString();
log.debug(payload);
TimeUnit.MILLISECONDS.sleep(1);
}
}
}
```
然后使用jmap不带和带live分别生成两个转储
```
jmap -dump:format=b,file=nolive.hprof 57323
jmap -dump:live,format=b,file=live.hprof 5732
```
可以看到nolive这个转储的不可到达对象包含了164MB char[](可以认为基本是字符串):
<img src="https://static001.geekbang.org/resource/image/8e/9b/8e4f3eea80edfe6d867cab754967589b.png" alt="">
而live这个转储只有1.3MB的char[]说明程序循环中的这些字符串都被GC了
<img src="https://static001.geekbang.org/resource/image/18/87/18403a0b683c3b726700d5624f968287.png" alt="">
**问题2**你有没有想过客户端是如何和MySQL进行认证的呢你能否对照[MySQL的文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake)使用Wireshark观察分析这一过程呢
答:一般而言,认证(握手)过程分为三步。
首先,服务端给客户端主动发送握手消息:
<img src="https://static001.geekbang.org/resource/image/29/b2/29f5e4a9056b7b6aeb9d9ac2yy5e97b2.png" alt="">
Wireshark已经把消息的字段做了解析你可以对比[官方文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake)的协议格式一起查看。HandshakeV10消息体的第一个字节是消息版本0a见图中红色框标注的部分。前面四个字节是MySQL的消息头其中前三个字节是消息体长度16进制4a=74字节最后一个字节是消息序列号。
然后客户端给服务端回复的HandshakeResponse41消息体包含了登录的用户名和密码
<img src="https://static001.geekbang.org/resource/image/a0/96/a0b37df4f3e92f7e8602409a7ca0f696.png" alt="">
可以看到用户名是string[NUL]类型的说明字符串以00结尾代表字符串结束。关于MySQL协议中的字段类型你可以参考[这里](https://dev.mysql.com/doc/internals/en/string.html)。
最后服务端回复的OK消息代表握手成功
<img src="https://static001.geekbang.org/resource/image/d7/e7/d746f34df74cfedb4d294db1e2b771e7.png" alt="">
这样分析下来我们可以发现使用Wireshark观察客户端和MySQL的认证过程非常方便。而如果不借助Wireshark工具我们只能一个字节一个字节地对照协议文档分析内容。
其实各种CS系统定义的通讯协议本身并不深奥甚至可以说对着协议文档写通讯客户端是体力活。你可以继续按照这里我说的方式结合抓包和文档分析一下MySQL的查询协议。
### [加餐5 | 分析定位Java问题一定要用好这些工具](https://time.geekbang.org/column/article/230534)
**问题:**Arthas还有一个强大的热修复功能。比如遇到高CPU问题时我们定位出是管理员用户会执行很多次MD5消耗大量CPU资源。这时我们可以直接在服务器上进行热修复步骤是jad命令反编译代码-&gt;使用文本编辑器比如Vim直接修改代码-&gt;使用sc命令查找代码所在类的ClassLoader-&gt;使用redefine命令热更新代码。你可以尝试使用这个流程直接修复程序注释doTask方法中的相关代码
Arthas的官方文档有[详细的操作步骤](https://alibaba.github.io/arthas/redefine.html)实现jad-&gt;sc-&gt;redefine的整个流程需要注意的是
- redefine命令和jad/watch/trace/monitor/tt等命令会冲突。执行完redefine之后如果再执行上面提到的命令则会把redefine的字节码重置。 原因是JDK本身redefine和Retransform是不同的机制同时使用两种机制来更新字节码只有最后的修改会生效。
- 使用redefine不允许新增或者删除field/method并且运行中的方法不会立即生效需要等下次运行才能生效。
以上就是咱们这门课里面5篇加餐文章的思考题答案了。至此咱们这个课程的“答疑篇”模块也就结束了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,387 @@
<audio id="audio" title="27 | 数据源头:任何客户端的东西都不可信任" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/11/849f7bc622c3b9cd6ba604906d16de11.mp3"></audio>
你好,我是朱晔。
从今天开始,我要和你讨论几个有关安全的话题。首先声明,我不是安全专家,但我发现有这么一个问题,那就是许多做业务开发的同学往往一点点安全意识都没有。如果有些公司没有安全部门或专家的话,安全问题就会非常严重。
如果只是用一些所谓的渗透服务浅层次地做一下扫描和渗透,而不在代码和逻辑层面做进一步分析的话,能够发现的安全问题非常有限。要做好安全,还是要靠一线程序员和产品经理点点滴滴的意识。
所以接下来的几篇文章,我会从业务开发的角度,和你说说我们应该最应该具备的安全意识。
对于HTTP请求我们要在脑子里有一个根深蒂固的概念那就是**任何客户端传过来的数据都是不能直接信任的**。客户端传给服务端的数据只是信息收集,数据需要经过有效性验证、权限验证等后才能使用,并且这些数据只能认为是用户操作的意图,不能直接代表数据当前的状态。
举一个简单的例子,我们打游戏的时候,客户端发给服务端的只是用户的操作,比如移动了多少位置,由服务端根据用户当前的状态来设置新的位置再返回给客户端。为了防止作弊,不可能由客户端直接告诉服务端用户当前的位置。
因此客户端发给服务端的指令代表的只是操作指令并不能直接决定用户的状态对于状态改变的计算在服务端。而网络不好时我们往往会遇到走了10步又被服务端拉回来的现象就是因为有指令丢失客户端使用服务端计算的实际位置修正了客户端玩家的位置。
今天,我通过四个案例来和你说说,为什么“任何客户端的东西都不可信任”。
## 客户端的计算不可信
我们先看一个电商下单操作的案例。
在这个场景下,可能会暴露这么一个/order的POST接口给客户端让客户端直接把组装后的订单信息Order传给服务端
```
@PostMapping(&quot;/order&quot;)
public void wrong(@RequestBody Order order) {
this.createOrder(order);
}
```
订单信息Order可能包括商品ID、商品价格、数量、商品总价
```
@Data
public class Order {
private long itemId; //商品ID
private BigDecimal itemPrice; //商品价格
private int quantity; //商品数量
private BigDecimal itemTotalPrice; //商品总价
}
```
虽然用户下单时客户端肯定有商品的价格等信息也会计算出订单的总价给用户确认但是这些信息只能用于呈现和核对。即使客户端传给服务端的POJO中包含了这些信息服务端也一定要重新从数据库来初始化商品的价格重新计算最终的订单价格。**如果不这么做的话,很可能会被黑客利用,商品总价被恶意修改为比较低的价格。**
因此我们真正直接使用的、可信赖的只是客户端传过来的商品ID和数量服务端会根据这些信息重新计算最终的总价。如果服务端计算出来的商品价格和客户端传过来的价格不匹配的话可以给客户端友好提示让用户重新下单。修改后的代码如下
```
@PostMapping(&quot;/orderRight&quot;)
public void right(@RequestBody Order order) {
//根据ID重新查询商品
Item item = Db.getItem(order.getItemId());
//客户端传入的和服务端查询到的商品单价不匹配的时候,给予友好提示
if (!order.getItemPrice().equals(item.getItemPrice())) {
throw new RuntimeException(&quot;您选购的商品价格有变化,请重新下单&quot;);
}
//重新设置商品单价
order.setItemPrice(item.getItemPrice());
//重新计算商品总价
BigDecimal totalPrice = item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity()));
//客户端传入的和服务端查询到的商品总价不匹配的时候,给予友好提示
if (order.getItemTotalPrice().compareTo(totalPrice)!=0) {
throw new RuntimeException(&quot;您选购的商品总价有变化,请重新下单&quot;);
}
//重新设置商品总价
order.setItemTotalPrice(totalPrice);
createOrder(order);
}
```
还有一种可行的做法是让客户端仅传入需要的数据给服务端像这样重新定义一个POJO CreateOrderRequest作为接口入参比直接使用领域模型Order更合理。在设计接口时我们会思考哪些数据需要客户端提供而不是把一个大而全的对象作为参数提供给服务端以避免因为忘记在服务端重置客户端数据而导致的安全问题。
下单成功后,服务端处理完成后会返回诸如商品单价、总价等信息给客户端。此时,客户端可以进行一次判断,如果和之前客户端的数据不一致的话,给予用户提示,用户确认没问题后再进入支付阶段:
```
@Data
public class CreateOrderRequest {
private long itemId; //商品ID
private int quantity; //商品数量
}
@PostMapping(&quot;orderRight2&quot;)
public Order right2(@RequestBody CreateOrderRequest createOrderRequest) {
//商品ID和商品数量是可信的没问题其他数据需要由服务端计算
Item item = Db.getItem(createOrderRequest.getItemId());
Order order = new Order();
order.setItemPrice(item.getItemPrice());
order.setItemTotalPrice(item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity())));
createOrder(order);
return order;
}
```
通过这个案例我们可以看到,在处理客户端提交过来的数据时,服务端需要明确区分,哪些数据是需要客户端提供的,哪些数据是客户端从服务端获取后在客户端计算的。其中,前者可以信任;而后者不可信任,服务端需要重新计算,如果客户端和服务端计算结果不一致的话,可以给予友好提示。
## 客户端提交的参数需要校验
对于客户端的数据,我们还容易忽略的一点是,**误以为客户端的数据来源是服务端,客户端就不可能提交异常数据**。我们看一个案例。
有一个用户注册页面要让用户选择所在国家我们会把服务端支持的国家列表返回给页面供用户选择。如下代码所示我们的注册只支持中国、美国和英国三个国家并不对其他国家开放因此从数据库中筛选了id&lt;4的国家返回给页面进行填充
```
@Slf4j
@RequestMapping(&quot;trustclientdata&quot;)
@Controller
public class TrustClientDataController {
//所有支持的国家
private HashMap&lt;Integer, Country&gt; allCountries = new HashMap&lt;&gt;();
public TrustClientDataController() {
allCountries.put(1, new Country(1, &quot;China&quot;));
allCountries.put(2, new Country(2, &quot;US&quot;));
allCountries.put(3, new Country(3, &quot;UK&quot;));
allCountries.put(4, new Country(4, &quot;Japan&quot;));
}
@GetMapping(&quot;/&quot;)
public String index(ModelMap modelMap) {
List&lt;Country&gt; countries = new ArrayList&lt;&gt;();
//从数据库查出ID&lt;4的三个国家作为白名单在页面显示
countries.addAll(allCountries.values().stream().filter(country -&gt; country.getId()&lt;4).collect(Collectors.toList()));
modelMap.addAttribute(&quot;countries&quot;, countries);
return &quot;index&quot;;
}
}
```
我们通过服务端返回的数据来渲染模板:
```
...
&lt;form id=&quot;myForm&quot; method=&quot;post&quot; th:action=&quot;@{/trustclientdata/wrong}&quot;&gt;
&lt;select id=&quot;countryId&quot; name=&quot;countryId&quot;&gt;
&lt;option value=&quot;0&quot;&gt;Select country&lt;/option&gt;
&lt;option th:each=&quot;country : ${countries}&quot; th:text=&quot;${country.name}&quot; th:value=&quot;${country.id}&quot;&gt;&lt;/option&gt;
&lt;/select&gt;
&lt;button th:text=&quot;Register&quot; type=&quot;submit&quot;/&gt;
&lt;/form&gt;
...
```
在页面上,的确也只有这三个国家的可选项:<br>
<img src="https://static001.geekbang.org/resource/image/cc/eb/cc68781b3806c45cbd8aeb3c62bdb8eb.png" alt="">
但我们要知道的是页面是给普通用户使用的而黑客不会在乎页面显示什么完全有可能尝试给服务端返回页面上没显示的其他国家ID。如果像这样直接信任客户端传来的国家ID的话很可能会把用户注册功能开放给其他国家的人
```
@PostMapping(&quot;/wrong&quot;)
@ResponseBody
public String wrong(@RequestParam(&quot;countryId&quot;) int countryId) {
return allCountries.get(countryId).getName();
}
```
即使我们知道参数的范围来自下拉框,而下拉框的内容也来自服务端,也需要对参数进行校验。因为接口不一定要通过浏览器请求,只要知道接口定义完全可以通过其他工具提交:
```
curl http://localhost:45678/trustclientdata/wrong\?countryId=4 -X POST
```
修改方式是,在使用客户端传过来的参数之前,对参数进行有效性校验:
```
@PostMapping(&quot;/right&quot;)
@ResponseBody
public String right(@RequestParam(&quot;countryId&quot;) int countryId) {
if (countryId &lt; 1 || countryId &gt; 3)
throw new RuntimeException(&quot;非法参数&quot;);
return allCountries.get(countryId).getName();
}
```
或者是使用Spring Validation采用注解的方式进行参数校验更优雅
```
@Validated
public class TrustClientParameterController {
@PostMapping(&quot;/better&quot;)
@ResponseBody
public String better(
@RequestParam(&quot;countryId&quot;)
@Min(value = 1, message = &quot;非法参数&quot;)
@Max(value = 3, message = &quot;非法参数&quot;) int countryId) {
return allCountries.get(countryId).getName();
}
}
```
客户端提交的参数需要校验的问题可以引申出一个更容易忽略的点是我们可能会把一些服务端的数据暂存在网页的隐藏域中这样下次页面提交的时候可以把相关数据再传给服务端。虽然用户通过网页界面的操作无法修改这些数据但这些数据对于HTTP请求来说就是普通数据完全可以随时修改为任意值。所以服务端在使用这些数据的时候也同样要特别小心。
## 不能信任请求头里的任何内容
刚才我们介绍了不能直接信任客户端的传参也就是通过GET或POST方法传过来的数据此外请求头的内容也不能信任。
一个比较常见的需求是为了防刷我们需要判断用户的唯一性。比如针对未注册的新用户发送一些小奖品我们不希望相同用户多次获得奖品。考虑到未注册的用户因为没有登录过所以没有用户标识我们可能会想到根据请求的IP地址来判断用户是否已经领过奖品。
比如下面的这段测试代码。我们通过一个HashSet模拟已发放过奖品的IP名单每次领取奖品后把IP地址加入这个名单中。IP地址的获取方式是优先通过X-Forwarded-For请求头来获取如果没有的话再通过HttpServletRequest的getRemoteAddr方法来获取。
```
@Slf4j
@RequestMapping(&quot;trustclientip&quot;)
@RestController
public class TrustClientIpController {
HashSet&lt;String&gt; activityLimit = new HashSet&lt;&gt;();
@GetMapping(&quot;test&quot;)
public String test(HttpServletRequest request) {
String ip = getClientIp(request);
if (activityLimit.contains(ip)) {
return &quot;您已经领取过奖品&quot;;
} else {
activityLimit.add(ip);
return &quot;奖品领取成功&quot;;
}
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader(&quot;X-Forwarded-For&quot;);
if (xff == null) {
return request.getRemoteAddr();
} else {
return xff.contains(&quot;,&quot;) ? xff.split(&quot;,&quot;)[0] : xff;
}
}
}
```
之所以这么做是因为通常我们的应用之前都部署了反向代理或负载均衡器remoteAddr获得的只能是代理的IP地址而不是访问用户实际的IP。这不符合我们的需求因为反向代理在转发请求时通常会把用户真实IP放入X-Forwarded-For这个请求头中。
**这种过于依赖X-Forwarded-For请求头来判断用户唯一性的实现方式是有问题的**
- 完全可以通过cURL类似的工具来模拟请求随意篡改头的内容
```
curl http://localhost:45678/trustclientip/test -H &quot;X-Forwarded-For:183.84.18.71, 10.253.15.1&quot;
```
- 网吧、学校等机构的出口IP往往是同一个在这个场景下可能只有最先打开这个页面的用户才能领取到奖品而其他用户会被阻拦。
因此IP地址或者说请求头里的任何信息包括Cookie中的信息、Referer只能用作参考不能用作重要逻辑判断的依据。而对于类似这个案例唯一性的判断需求更好的做法是让用户进行登录或三方授权登录比如微信拿到用户标识来做唯一性判断。
## 用户标识不能从客户端获取
聊到用户登录业务代码非常容易犯错的一个地方是使用了客户端传给服务端的用户ID类似这样
```
@GetMapping(&quot;wrong&quot;)
public String wrong(@RequestParam(&quot;userId&quot;) Long userId) {
return &quot;当前用户Id&quot; + userId;
}
```
你可能觉得没人会这么干,但我就真实遇到过:**一个大项目因为服务端直接使用了客户端传过来的用户标识,导致了安全问题**。
犯类似低级错误的原因,有三个:
- 开发同学没有正确认识接口或服务面向的用户。如果接口面向内部服务由服务调用方传入用户ID没什么不合理但是这样的接口不能直接开放给客户端或H5使用。
- 在测试阶段为了方便测试调试,我们通常会实现一些无需登录即可使用的接口,直接使用客户端传过来的用户标识,却在上线之前忘记删除类似的超级接口。
- 一个大型网站前端可能由不同的模块构成不一定是一个系统而用户登录状态可能也没有打通。有些时候我们图简单可能会在URL中直接传用户ID以实现通过前端传值来打通用户登录状态。
如果你的接口直面用户比如给客户端或H5页面调用那么一定需要用户先登录才能使用。登录后用户标识保存在服务端接口需要从服务端比如Session中获取。这里有段代码演示了一个最简单的登录操作登录后在Session中设置了当前用户的标识
```
@GetMapping(&quot;login&quot;)
public long login(@RequestParam(&quot;username&quot;) String username, @RequestParam(&quot;password&quot;) String password, HttpSession session) {
if (username.equals(&quot;admin&quot;) &amp;&amp; password.equals(&quot;admin&quot;)) {
session.setAttribute(&quot;currentUser&quot;, 1L);
return 1L;
}
return 0L;
}
```
这里我再分享一个Spring Web的小技巧。
如果希望每一个需要登录的方法都从Session中获得当前用户标识并进行一些后续处理的话我们没有必要在每一个方法内都复制粘贴相同的获取用户身份的逻辑可以定义一个自定义注解@LoginRequired到userId参数上然后通过HandlerMethodArgumentResolver自动实现参数的组装
```
@GetMapping(&quot;right&quot;)
public String right(@LoginRequired Long userId) {
return &quot;当前用户Id&quot; + userId;
}
```
@LoginRequired本身并无特殊,只是一个自定义注解:
```
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface LoginRequired {
String sessionKey() default &quot;currentUser&quot;;
}
```
魔法来自HandlerMethodArgumentResolver。我们自定义了一个实现类LoginRequiredArgumentResolver实现了HandlerMethodArgumentResolver接口的2个方法
- supportsParameter方法判断当参数上有@LoginRequired注解时,再做自定义参数解析的处理;
- resolveArgument方法用来实现解析逻辑本身。在这里我们尝试从Session中获取当前用户的标识如果无法获取到的话提示非法调用的错误如果获取到则返回userId。这样一来Controller中的userId参数就可以自动赋值了。
```
@Slf4j
public class LoginRequiredArgumentResolver implements HandlerMethodArgumentResolver {
//解析哪些参数
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
//匹配参数上具有@LoginRequired注解的参数
return methodParameter.hasParameterAnnotation(LoginRequired.class);
}
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
//从参数上获得注解
LoginRequired loginRequired = methodParameter.getParameterAnnotation(LoginRequired.class);
//根据注解中的Session Key从Session中查询用户信息
Object object = nativeWebRequest.getAttribute(loginRequired.sessionKey(), NativeWebRequest.SCOPE_SESSION);
if (object == null) {
log.error(&quot;接口 {} 非法调用!&quot;, methodParameter.getMethod().toString());
throw new RuntimeException(&quot;请先登录!&quot;);
}
return object;
}
}
```
当然我们要实现WebMvcConfigurer接口的addArgumentResolvers方法来增加这个自定义的处理器LoginRequiredArgumentResolver
```
SpringBootApplication
public class CommonMistakesApplication implements WebMvcConfigurer {
...
@Override
public void addArgumentResolvers(List&lt;HandlerMethodArgumentResolver&gt; resolvers) {
resolvers.add(new LoginRequiredArgumentResolver());
}
}
```
测试发现,经过这样的实现,登录后所有需要登录的方法都可以一键通过加@LoginRequired注解来拿到用户标识,方便且安全:<br>
<img src="https://static001.geekbang.org/resource/image/44/1e/444b314beb2be68c6574e12d65463b1e.png" alt="">
## 重点回顾
今天,我就“任何客户端的东西都不可信任”这个结论,和你讲解了一些有代表性的错误。
第一,客户端的计算不可信。虽然目前很多项目的前端都是富前端,会做大量的逻辑计算,无需访问服务端接口就可以顺畅完成各种功能,但来自客户端的计算结果不能直接信任。最终在进行业务操作时,客户端只能扮演信息收集的角色,虽然可以将诸如价格等信息传给服务端,但只能用于校对比较,最终要以服务端的计算结果为准。
第二所有来自客户端的参数都需要校验判断合法性。即使我们知道用户是在一个下拉列表选择数据即使我们知道用户通过网页正常操作不可能提交不合法的值服务端也应该进行参数校验防止非法用户绕过浏览器UI页面通过工具直接向服务端提交参数。
第三除了请求Body中的信息请求头里的任何信息同样不能信任。我们要知道来自请求头的IP、Referer和Cookie都有被篡改的可能性相关数据只能用来参考和记录不能用作重要业务逻辑。
第四,如果接口面向外部用户,那么一定不能出现用户标识这样的参数,当前用户的标识一定来自服务端,只有经过身份认证后的用户才会在服务端留下标识。如果你的接口现在面向内部其他服务,那么也要千万小心这样的接口只能内部使用,还可能需要进一步考虑服务端调用方的授权问题。
安全问题是木桶效应,整个系统的安全等级取决于安全性最薄弱的那个模块。在写业务代码的时候,要从我做起,建立最基本的安全意识,从源头杜绝低级安全问题。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在讲述用户标识不能从客户端获取这个要点的时候我提到开发同学可能会因为用户信息未打通而通过前端来传用户ID。那我们有什么好办法来打通不同的系统甚至不同网站的用户标识吗
1. 还有一类和客户端数据相关的漏洞非常重要那就是URL地址中的数据。在把匿名用户重定向到登录页面的时候我们一般会带上redirectUrl这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接由真实的网站+钓鱼的redirectUrl构成发邮件诱导用户进行登录。用户登录时访问的其实是真的网站所以不容易察觉到redirectUrl是钓鱼网站登录后却来到了钓鱼网站用户可能会不知不觉就把重要信息泄露了。这种安全问题我们叫做开放重定向问题。你觉得从代码层面应该怎么预防开放重定向问题呢
你还遇到过因为信任HTTP请求中客户端传给服务端的信息导致的安全问题吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="28 | 安全兜底:涉及钱时,必须考虑防刷、限量和防重" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/e0/5dd2bb30f551cde52711cf7486a92be0.mp3"></audio>
你好,我是朱晔。今天,我要和你分享的主题是,任何涉及钱的代码必须要考虑防刷、限量和防重,要做好安全兜底。
涉及钱的代码,主要有以下三类。
第一,代码本身涉及有偿使用的三方服务。如果因为代码本身缺少授权、用量控制而被利用导致大量调用,势必会消耗大量的钱,给公司造成损失。有些三方服务可能采用后付款方式的结算,出现问题后如果没及时发现,下个月结算时就会收到一笔数额巨大的账单。
第二,代码涉及虚拟资产的发放,比如积分、优惠券等。虽然说虚拟资产不直接对应货币,但一般可以在平台兑换具有真实价值的资产。比如,优惠券可以在下单时使用,积分可以兑换积分商城的商品。所以从某种意义上说,虚拟资产就是具有一定价值的钱,但因为不直接涉及钱和外部资金通道,所以容易产生随意性发放而导致漏洞。
第三代码涉及真实钱的进出。比如对用户扣款如果出现非正常的多次重复扣款小则用户投诉、用户流失大则被相关管理机构要求停业整改影响业务。又比如给用户发放返现的付款功能如果出现漏洞造成重复付款涉及B端的可能还好但涉及C端用户的重复付款可能永远无法追回。
前段时间拼多多一夜之间被刷了大量100元无门槛优惠券的事情就是限量和防刷出了问题。
今天,我们就通过三个例子,和你说明如何在代码层面做好安全兜底。
## 开放平台资源的使用需要考虑防刷
我以真实遇到的短信服务被刷案例,和你说说防刷。
有次短信账单月结时发现,之前每个月是几千元的短信费用,这个月突然变为了几万元。查数据库记录发现,之前是每天发送几千条短信验证码,从某天开始突然变为了每天几万条,但注册用户数并没有激增。显然,这是短信接口被刷了。
我们知道,短信验证码服务属于开放性服务,由用户侧触发,且因为是注册验证码所以不需要登录就可以使用。如果我们的发短信接口像这样没有任何防刷的防护,直接调用三方短信通道,就相当于“裸奔”,很容易被短信轰炸平台利用:
```
@GetMapping(&quot;wrong&quot;)
public void wrong() {
sendSMSCaptcha(&quot;13600000000&quot;);
}
private void sendSMSCaptcha(String mobile) {
//调用短信通道
}
```
对于短信验证码这种开放接口程序逻辑内需要有防刷逻辑。好的防刷逻辑是对正常使用的用户毫无影响只有疑似异常使用的用户才会感受到。对于短信验证码有如下4种可行的方式来防刷。
第一种方式,**只有固定的请求头才能发送验证码。**
也就是说我们通过请求头中网页或App客户端传给服务端的一些额外参数来判断请求是不是App发起的。其实这种方式“防君子不防小人”。
比如判断是否存在浏览器或手机型号、设备分辨率请求头。对于那些使用爬虫来抓取短信接口地址的程序来说往往只能抓取到URL而难以分析出请求发送短信还需要的额外请求头可以看作第一道基本防御。
第二种方式,**只有先到过注册页面才能发送验证码。**
对于普通用户来说不管是通过App注册还是H5页面注册一定是先进入注册页面才能看到发送验证码按钮再点击发送。我们可以在页面或界面打开时请求固定的前置接口为这个设备开启允许发送验证码的窗口之后的请求发送验证码才是有效请求。
这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
第三种方式,**控制相同手机号的发送次数和发送频次。**
除非是短信无法收到否则用户不太会请求了验证码后不完成注册流程再重新请求。因此我们可以限制同一手机号每天的最大请求次数。验证码的到达需要时间太短的发送间隔没有意义所以我们还可以控制发送的最短间隔。比如我们可以控制相同手机号一天只能发送10次验证码最短发送间隔1分钟。
第四种方式,**增加前置图形验证码。**
短信轰炸平台一般会收集很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发送次数和间隔的方式不够有效。这时,我们可以考虑对用户体验稍微有影响,但也是最有效的方式作为保底,即将弹出图形验证码作为前置。
除了图形验证码,我们还可以使用其他更友好的人机验证手段(比如滑动、点击验证码等),甚至是引入比较新潮的无感知验证码方案(比如,通过判断用户输入手机号的打字节奏,来判断是用户还是机器),来改善用户体验。
此外我们也可以考虑在监测到异常的情况下再弹出人机检测。比如短时间内大量相同远端IP发送验证码的时候才会触发人机检测。
总之,我们要确保,只有正常用户经过正常的流程才能使用开放平台资源,并且资源的用量在业务需求合理范围内。此外,还需要考虑做好短信发送量的实时监控,遇到发送量激增要及时报警。
接下来,我们一起看看限量的问题。
## 虚拟资产并不能凭空产生无限使用
虚拟资产虽然是平台方自己生产和控制但如果生产出来可以立即使用就有立即变现的可能性。比如因为平台Bug有大量用户领取高额优惠券并立即下单使用。
在商家看来,这很可能只是一个用户支付的订单,并不会感知到用户使用平台方优惠券的情况;同时,因为平台和商家是事后结算的,所以会马上安排发货。而发货后基本就不可逆了,一夜之间造成了大量资金损失。
我们从代码层面模拟一个优惠券被刷的例子。
假设有一个CouponCenter类负责优惠券的产生和发放。如下是错误做法只要调用方需要就可以凭空产生无限的优惠券
```
@Slf4j
public class CouponCenter {
//用于统计发了多少优惠券
AtomicInteger totalSent = new AtomicInteger(0);
public void sendCoupon(Coupon coupon) {
if (coupon != null)
totalSent.incrementAndGet();
}
public int getTotalSentCoupon() {
return totalSent.get();
}
//没有任何限制,来多少请求生成多少优惠券
public Coupon generateCouponWrong(long userId, BigDecimal amount) {
return new Coupon(userId, amount);
}
}
```
这样一来使用CouponCenter的generateCouponWrong方法想发多少优惠券就可以发多少
```
@GetMapping(&quot;wrong&quot;)
public int wrong() {
CouponCenter couponCenter = new CouponCenter();
//发送10000个优惠券
IntStream.rangeClosed(1, 10000).forEach(i -&gt; {
Coupon coupon = couponCenter.generateCouponWrong(1L, new BigDecimal(&quot;100&quot;));
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
```
**更合适的做法是,把优惠券看作一种资源,其生产不是凭空的,而是需要事先申请**,理由是:
- 虚拟资产如果最终可以对应到真实金钱上的优惠,那么,能发多少取决于运营和财务的核算,应该是有计划、有上限的。引言提到的无门槛优惠券,需要特别小心。有门槛优惠券的大量使用至少会带来大量真实的消费,而使用无门槛优惠券下的订单,可能用户一分钱都没有支付。
- 即使虚拟资产不值钱,大量不合常规的虚拟资产流入市场,也会冲垮虚拟资产的经济体系,造成虚拟货币的极速贬值。有量的控制才有价值。
- 资产的申请需要理由,甚至需要走流程,这样才可以追溯是什么活动需要、谁提出的申请,程序依据申请批次来发放。
接下来,我们按照这个思路改进一下程序。
首先定义一个CouponBatch类要产生优惠券必须先向运营申请优惠券批次批次中包含了固定张数的优惠券、申请原因等信息
```
//优惠券批次
@Data
public class CouponBatch {
private long id;
private AtomicInteger totalCount;
private AtomicInteger remainCount;
private BigDecimal amount;
private String reason;
}
```
在业务需要发放优惠券的时候,先申请批次,然后再通过批次发放优惠券:
```
@GetMapping(&quot;right&quot;)
public int right() {
CouponCenter couponCenter = new CouponCenter();
//申请批次
CouponBatch couponBatch = couponCenter.generateCouponBatch();
IntStream.rangeClosed(1, 10000).forEach(i -&gt; {
Coupon coupon = couponCenter.generateCouponRight(1L, couponBatch);
//发放优惠券
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
```
可以看到generateCouponBatch方法申请批次时设定了这个批次包含100张优惠券。在通过generateCouponRight方法发放优惠券时每发一次都会从批次中扣除一张优惠券发完了就没有了
```
public Coupon generateCouponRight(long userId, CouponBatch couponBatch) {
if (couponBatch.getRemainCount().decrementAndGet() &gt;= 0) {
return new Coupon(userId, couponBatch.getAmount());
} else {
log.info(&quot;优惠券批次 {} 剩余优惠券不足&quot;, couponBatch.getId());
return null;
}
}
public CouponBatch generateCouponBatch() {
CouponBatch couponBatch = new CouponBatch();
couponBatch.setAmount(new BigDecimal(&quot;100&quot;));
couponBatch.setId(1L);
couponBatch.setTotalCount(new AtomicInteger(100));
couponBatch.setRemainCount(couponBatch.getTotalCount());
couponBatch.setReason(&quot;XXX活动&quot;);
return couponBatch;
}
```
这样改进后的程序一个批次最多只能发放100张优惠券<br>
<img src="https://static001.geekbang.org/resource/image/c9/cb/c971894532afd5f5150a6ab2fc0833cb.png" alt="">
因为是Demo所以我们只是凭空new出来一个Coupon。在真实的生产级代码中一定是根据CouponBatch在数据库中插入一定量的Coupon记录每一个优惠券都有唯一的ID可跟踪、可注销。
最后,我们再看看防重。
## 钱的进出一定要和订单挂钩并且实现幂等
涉及钱的进出,需要做好以下两点。
第一,**任何资金操作都需要在平台侧生成业务属性的订单,可以是优惠券发放订单,可以是返现订单,也可以是借款订单,一定是先有订单再去做资金操作**。同时,订单的产生需要有业务属性。业务属性是指,订单不是凭空产生的,否则就没有控制的意义。比如,返现发放订单必须关联到原先的商品订单产生;再比如,借款订单必须关联到同一个借款合同产生。
第二,**一定要做好防重,也就是实现幂等处理,并且幂等处理必须是全链路的**。这里的全链路是指,从前到后都需要有相同的业务订单号来贯穿,实现最终的支付防重。
关于这两点,你可以参考下面的代码示例:
```
//错误每次使用UUID作为订单号
@GetMapping(&quot;wrong&quot;)
public void wrong(@RequestParam(&quot;orderId&quot;) String orderId) {
PayChannel.pay(UUID.randomUUID().toString(), &quot;123&quot;, new BigDecimal(&quot;100&quot;));
}
//正确:使用相同的业务订单号
@GetMapping(&quot;right&quot;)
public void right(@RequestParam(&quot;orderId&quot;) String orderId) {
PayChannel.pay(orderId, &quot;123&quot;, new BigDecimal(&quot;100&quot;));
}
//三方支付通道
public class PayChannel {
public static void pay(String orderId, String account, BigDecimal amount) {
...
}
}
```
对于支付操作,我们一定是调用三方支付公司的接口或银行接口进行处理的。一般而言,这些接口都会有商户订单号的概念,对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的幂等处理。
但是,业务系统在实现资金操作时容易犯的错是,没有自始至终地使用一个订单号作为商户订单号,透传给三方支付接口。出现这个问题的原因是,比较大的互联网公司一般会把支付独立一个部门。支付部门可能会针对支付做聚合操作,内部会维护一个支付订单号,然后使用支付订单号和三方支付接口交互。最终虽然商品订单是一个,但支付订单是多个,相同的商品订单因为产生多个支付订单导致多次支付。
如果说,支付出现了重复扣款,我们可以给用户进行退款操作,但给用户付款的操作一旦出现重复付款,就很难把钱追回来了,所以更要小心。
这,就是全链路的意义,从一开始就需要先有业务订单产生,然后使用相同的业务订单号一直贯穿到最后的资金通路,才能真正避免重复资金操作。
## 重点回顾
今天,我从安全兜底聊起,和你分享了涉及钱的业务最需要做的三方面工作,防刷、限量和防重。
第一,使用开放的、面向用户的平台资源要考虑防刷,主要包括正常使用流程识别、人机识别、单人限量和全局限量等手段。
第二,虚拟资产不能凭空产生,一定是先有发放计划、申请批次,然后通过批次来生产资产。这样才能达到限量、有审计、能追溯的目的。
第三,真实钱的进出操作要额外小心,做好防重处理。不能凭空去操作用户的账户,每次操作以真实的订单作为依据,通过业务订单号实现全链路的幂等控制。
如果程序逻辑涉及有价值的资源或是真实的钱,我们必须有敬畏之心。程序上线后,人是有休息时间的,但程序是一直运行着的,如果产生安全漏洞,就很可能在一夜之间爆发,被大量人利用导致大量的金钱损失。
除了在流程上做好防刷、限量和防重控制之外,我们还需要做好三方平台调用量、虚拟资产使用量、交易量、交易金额等重要数据的监控报警,这样即使出现问题也能第一时间发现。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 防重、防刷都是事前手段,如果我们的系统正在被攻击或利用,你有什么办法及时发现问题吗?
1. 任何三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量,你觉得一般是什么问题引起的呢?
有关安全兜底,你还有什么心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,743 @@
<audio id="audio" title="29 | 数据和代码:数据就是数据,代码就是代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/6c/4a906366dfc1d7d0a4063d51dbb93d6c.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊数据和代码的问题。
正如这一讲标题“数据就是数据代码就是代码”所说Web安全方面的很多漏洞都是源自把数据当成了代码来执行也就是注入类问题比如
- 客户端提供给服务端的查询值是一个数据会成为SQL查询的一部分。黑客通过修改这个值注入一些SQL来达到在服务端运行SQL的目的相当于把查询条件的数据变为了查询代码。这种攻击方式叫做SQL注入。
- 对于规则引擎我们可能会用动态语言做一些计算和SQL注入一样外部传入的数据只能当做数据使用如果被黑客利用传入了代码那么代码可能就会被动态执行。这种攻击方式叫做代码注入。
- 对于用户注册、留言评论等功能服务端会从客户端收集一些信息本来用户名、邮箱这类信息是纯文本信息但是黑客把信息替换为了JavaScript代码。那么这些信息在页面呈现时可能就相当于执行了JavaScript代码。甚至是服务端可能把这样的代码当作普通信息保存到了数据库。黑客通过构建JavaScript代码来实现修改页面呈现、盗取信息甚至蠕虫攻击的方式叫做XSS跨站脚本攻击。
今天,我们就通过案例来看一下这三个问题,并了解下应对方式。
## SQL注入能干的事情比你想象的更多
我们应该都听说过SQL注入也可能知道最经典的SQL注入的例子是通过构造or1='1作为密码实现登录。这种简单的攻击方式在十几年前可以突破很多后台的登录但现在很难奏效了。
最近几年我们的安全意识增强了都知道使用参数化查询来避免SQL注入问题。其中的原理是使用参数化查询的话参数只能作为普通数据不可能作为SQL的一部分以此有效避免SQL注入问题。
虽然我们已经开始关注SQL注入的问题但还是有一些认知上的误区主要表现在以下三个方面
第一,**认为SQL注入问题只可能发生于Http Get请求也就是通过URL传入的参数才可能产生注入点**。这是很危险的想法。从注入的难易度上来说修改URL上的QueryString和修改Post请求体中的数据没有任何区别因为黑客是通过工具来注入的而不是通过修改浏览器上的URL来注入的。甚至Cookie都可以用来SQL注入任何提供数据的地方都可能成为注入点。
第二,**认为不返回数据的接口,不可能存在注入问题**。其实黑客完全可以利用SQL语句构造出一些不正确的SQL导致执行出错。如果服务端直接显示了错误信息那黑客需要的数据就有可能被带出来从而达到查询数据的目的。甚至是即使没有详细的出错信息黑客也可以通过所谓盲注的方式进行攻击。我后面再具体解释。
第三,**认为SQL注入的影响范围只是通过短路实现突破登录只需要登录操作加强防范即可**。首先SQL注入完全可以实现拖库也就是下载整个数据库的内容之后我们会演示SQL注入的危害不仅仅是突破后台登录。其次根据木桶原理整个站点的安全性受限于安全级别最低的那块短板。因此对于安全问题站点的所有模块必须一视同仁并不是只加强防范所谓的重点模块。
在日常开发中虽然我们是使用框架来进行数据访问的但还可能会因为疏漏而导致注入问题。接下来我就用一个实际的例子配合专业的SQL注入工具[sqlmap](https://github.com/sqlmapproject/sqlmap)来测试下SQL注入。
首先在程序启动的时候使用JdbcTemplate创建一个userdata表表中只有ID、用户名、密码三列并初始化两条用户信息。然后创建一个不返回任何数据的Http Post接口。在实现上我们通过SQL拼接的方式把传入的用户名入参拼接到LIKE子句中实现模糊查询。
```
//程序启动时进行表结构和数据初始化
@PostConstruct
public void init() {
//删除表
jdbcTemplate.execute(&quot;drop table IF EXISTS `userdata`;&quot;);
//创建表不包含自增ID、用户名、密码三列
jdbcTemplate.execute(&quot;create TABLE `userdata` (\n&quot; +
&quot; `id` bigint(20) NOT NULL AUTO_INCREMENT,\n&quot; +
&quot; `name` varchar(255) NOT NULL,\n&quot; +
&quot; `password` varchar(255) NOT NULL,\n&quot; +
&quot; PRIMARY KEY (`id`)\n&quot; +
&quot;) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;&quot;);
//插入两条测试数据
jdbcTemplate.execute(&quot;INSERT INTO `userdata` (name,password) VALUES ('test1','haha1'),('test2','haha2')&quot;);
}
@Autowired
private JdbcTemplate jdbcTemplate;
//用户模糊搜索接口
@PostMapping(&quot;jdbcwrong&quot;)
public void jdbcwrong(@RequestParam(&quot;name&quot;) String name) {
//采用拼接SQL的方式把姓名参数拼到LIKE子句中
log.info(&quot;{}&quot;, jdbcTemplate.queryForList(&quot;SELECT id,name FROM userdata WHERE name LIKE '%&quot; + name + &quot;%'&quot;));
}
```
使用sqlmap来探索这个接口
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test
```
一段时间后sqlmap给出了如下结果
<img src="https://static001.geekbang.org/resource/image/2f/59/2f8e8530dd0f76778c45333adfad5259.png" alt="">
可以看到这个接口的name参数有两种可能的注入方式一种是报错注入一种是基于时间的盲注。
接下来,**仅需简单的三步,就可以直接导出整个用户表的内容了**。
第一步,查询当前数据库:
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --current-db
```
可以得到当前数据库是common_mistakes
```
current database: 'common_mistakes'
```
第二步,查询数据库下的表:
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --tables -D &quot;common_mistakes&quot;
```
可以看到其中有一个敏感表userdata
```
Database: common_mistakes
[7 tables]
+--------------------+
| user |
| common_store |
| hibernate_sequence |
| m |
| news |
| r |
| userdata |
+--------------------+
```
第三步查询userdata的数据
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test -D &quot;common_mistakes&quot; -T &quot;userdata&quot; --dump
```
你看,**用户密码信息一览无遗。当然,你也可以继续查看其他表的数据**
```
Database: common_mistakes
Table: userdata
[2 entries]
+----+-------+----------+
| id | name | password |
+----+-------+----------+
| 1 | test1 | haha1 |
| 2 | test2 | haha2 |
+----+-------+----------+
```
在日志中可以看到sqlmap实现拖库的方式是让SQL执行后的出错信息包含字段内容。注意看下错误日志的第二行错误信息中包含ID为2的用户的密码字段的值“haha2”。这就是报错注入的基本原理
```
[13:22:27.375] [http-nio-45678-exec-10] [ERROR] [o.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DuplicateKeyException: StatementCallback; SQL [SELECT id,name FROM userdata WHERE name LIKE '%test'||(SELECT 0x694a6e64 WHERE 3941=3941 AND (SELECT 9927 FROM(SELECT COUNT(*),CONCAT(0x71626a7a71,(SELECT MID((IFNULL(CAST(password AS NCHAR),0x20)),1,54) FROM common_mistakes.userdata ORDER BY id LIMIT 1,1),0x7170706271,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a))||'%']; Duplicate entry 'qbjzqhaha2qppbq1' for key '&lt;group_key&gt;'; nested exception is java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '&lt;group_key&gt;'] with root cause
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '&lt;group_key&gt;'
```
既然是这样我们就实现一个ExceptionHandler来屏蔽异常看看能否解决注入问题
```
@ExceptionHandler
public void handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
log.warn(String.format(&quot;访问 %s -&gt; %s 出现异常!&quot;, req.getRequestURI(), method.toString()), ex);
}
```
重启程序后重新运行刚才的sqlmap命令可以看到报错注入是没戏了但使用时间盲注还是可以查询整个表的数据
<img src="https://static001.geekbang.org/resource/image/76/c4/76ec4c2217cc5ac190b578e7236dc9c4.png" alt="">
所谓盲注指的是注入后并不能从服务器得到任何执行结果甚至是错误信息只能寄希望服务器对于SQL中的真假条件表现出不同的状态。比如对于布尔盲注来说可能是“真”可以得到200状态码“假”可以得到500错误状态码或者“真”可以得到内容输出“假”得不到任何输出。总之对于不同的SQL注入可以得到不同的输出即可。
在这个案例中因为接口没有输出也彻底屏蔽了错误布尔盲注这招儿行不通了。那么退而求其次的方式就是时间盲注。也就是说通过在真假条件中加入SLEEP来实现通过判断接口的响应时间知道条件的结果是真还是假。
不管是什么盲注,都是通过真假两种状态来完成的。你可能会好奇,通过真假两种状态如何实现数据导出?
其实你可以想一下我们虽然不能直接查询出password字段的值但可以按字符逐一来查判断第一个字符是否是a、是否是b……查询到h时发现响应变慢了自然知道这就是真的得出第一位就是h。以此类推可以查询出整个值。
所以sqlmap在返回数据的时候也是一个字符一个字符跳出结果的并且时间盲注的整个过程会比报错注入慢许多。
你可以引入[p6spy](https://github.com/p6spy/p6spy)工具打印出所有执行的SQL观察sqlmap构造的一些SQL来分析其中原理
```
&lt;dependency&gt;
&lt;groupId&gt;com.github.gavlyukovskiy&lt;/groupId&gt;
&lt;artifactId&gt;p6spy-spring-boot-starter&lt;/artifactId&gt;
&lt;version&gt;1.6.1&lt;/version&gt;
&lt;/dependency&gt;
```
<img src="https://static001.geekbang.org/resource/image/5d/0d/5d9a582025bb06adf863ae21ccb9280d.png" alt="">
所以说即使屏蔽错误信息错误码也不能彻底防止SQL注入。真正的解决方式还是使用参数化查询让任何外部输入值只可能作为数据来处理。
比如,对于之前那个接口,**在SQL语句中使用“?”作为参数占位符,然后提供参数值。**这样修改后sqlmap也就无能为力了
```
@PostMapping(&quot;jdbcright&quot;)
public void jdbcright(@RequestParam(&quot;name&quot;) String name) {
log.info(&quot;{}&quot;, jdbcTemplate.queryForList(&quot;SELECT id,name FROM userdata WHERE name LIKE ?&quot;, &quot;%&quot; + name + &quot;%&quot;));
}
```
**对于MyBatis来说同样需要使用参数化的方式来写SQL语句。在MyBatis中“#{}”是参数化的方式,“${}”只是占位符替换。**
比如LIKE语句。因为使用“#{}”会为参数带上单引号导致LIKE语法错误所以一些同学会退而求其次选择“${}”的方式,比如:
```
@Select(&quot;SELECT id,name FROM `userdata` WHERE name LIKE '%${name}%'&quot;)
List&lt;UserData&gt; findByNameWrong(@Param(&quot;name&quot;) String name);
```
你可以尝试一下使用sqlmap同样可以实现注入。正确的做法是使用“#{}”来参数化name参数对于LIKE操作可以使用CONCAT函数来拼接%符号:
```
@Select(&quot;SELECT id,name FROM `userdata` WHERE name LIKE CONCAT('%',#{name},'%')&quot;)
List&lt;UserData&gt; findByNameRight(@Param(&quot;name&quot;) String name);
```
又比如IN子句。因为涉及多个元素的拼接一些同学不知道如何处理也可能会选择使用“${}”。因为使用“#{}”会把输入当做一个字符串来对待:
```
&lt;select id=&quot;findByNamesWrong&quot; resultType=&quot;org.geekbang.time.commonmistakes.codeanddata.sqlinject.UserData&quot;&gt;
SELECT id,name FROM `userdata` WHERE name in (${names})
&lt;/select&gt;
```
但是这样直接把外部传入的内容替换到IN内部同样会有注入漏洞
```
@PostMapping(&quot;mybatiswrong2&quot;)
public List mybatiswrong2(@RequestParam(&quot;names&quot;) String names) {
return userDataMapper.findByNamesWrong(names);
}
```
你可以使用下面这条命令测试下:
```
python sqlmap.py -u http://localhost:45678/sqlinject/mybatiswrong2 --data names=&quot;'test1','test2'&quot;
```
最后可以发现有4种可行的注入方式分别是布尔盲注、报错注入、时间盲注和联合查询注入
<img src="https://static001.geekbang.org/resource/image/bd/d3/bdc7a7bcb34b59396f4a99d62425d6d3.png" alt="">
修改方式是给MyBatis传入一个List然后使用其foreach标签来拼接出IN中的内容并确保IN中的每一项都是使用“#{}”来注入参数:
```
@PostMapping(&quot;mybatisright2&quot;)
public List mybatisright2(@RequestParam(&quot;names&quot;) List&lt;String&gt; names) {
return userDataMapper.findByNamesRight(names);
}
&lt;select id=&quot;findByNamesRight&quot; resultType=&quot;org.geekbang.time.commonmistakes.codeanddata.sqlinject.UserData&quot;&gt;
SELECT id,name FROM `userdata` WHERE name in
&lt;foreach collection=&quot;names&quot; item=&quot;item&quot; open=&quot;(&quot; separator=&quot;,&quot; close=&quot;)&quot;&gt;
#{item}
&lt;/foreach&gt;
&lt;/select&gt;
```
修改后这个接口就不会被注入了,你可以自行测试一下。
## 小心动态执行代码时代码注入漏洞
总结下我们刚刚看到的SQL注入漏洞的原因是黑客把SQL攻击代码通过传参混入SQL语句中执行。同样对于任何解释执行的其他语言代码也可以产生类似的注入漏洞。我们看一个动态执行JavaScript代码导致注入漏洞的案例。
现在我们要对用户名实现动态的规则判断通过ScriptEngineManager获得一个JavaScript脚本引擎使用Java代码来动态执行JavaScript代码实现当外部传入的用户名为admin的时候返回1否则返回0
```
private ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
//获得JavaScript脚本引擎
private ScriptEngine jsEngine = scriptEngineManager.getEngineByName(&quot;js&quot;);
@GetMapping(&quot;wrong&quot;)
public Object wrong(@RequestParam(&quot;name&quot;) String name) {
try {
//通过eval动态执行JavaScript脚本这里name参数通过字符串拼接方式混入JavaScript代码
return jsEngine.eval(String.format(&quot;var name='%s'; name=='admin'?1:0;&quot;, name));
} catch (ScriptException e) {
e.printStackTrace();
}
return null;
}
```
这个功能本身没什么问题:
<img src="https://static001.geekbang.org/resource/image/a5/08/a5c253d78b6b40f6e2aa8283732f0408.png" alt="">
但是,如果我们把传入的用户名修改为这样:
```
haha';java.lang.System.exit(0);'
```
就可以达到关闭整个程序的目的。原因是我们直接把代码和数据拼接在了一起。外部如果构造了一个特殊的用户名先闭合字符串的单引号再执行一条System.exit命令的话就可以满足脚本不出错命令被执行。
解决这个问题有两种方式。
第一种方式和解决SQL注入一样需要**把外部传入的条件数据仅仅当做数据来对待。我们可以通过SimpleBindings来绑定参数初始化name变量**,而不是直接拼接代码:
```
@GetMapping(&quot;right&quot;)
public Object right(@RequestParam(&quot;name&quot;) String name) {
try {
//外部传入的参数
Map&lt;String, Object&gt; parm = new HashMap&lt;&gt;();
parm.put(&quot;name&quot;, name);
//name参数作为绑定传给eval方法而不是拼接JavaScript代码
return jsEngine.eval(&quot;name=='admin'?1:0;&quot;, new SimpleBindings(parm));
} catch (ScriptException e) {
e.printStackTrace();
}
return null;
}
```
这样就避免了注入问题:
<img src="https://static001.geekbang.org/resource/image/a0/49/a032842a5e551db18bd45dacf7794a49.png" alt="">
第二种解决方法是使用SecurityManager配合AccessControlContext来构建一个脚本运行的沙箱环境。脚本能执行的所有操作权限是通过setPermissions方法精细化设置的
```
@Slf4j
public class ScriptingSandbox {
private ScriptEngine scriptEngine;
private AccessControlContext accessControlContext;
private SecurityManager securityManager;
private static ThreadLocal&lt;Boolean&gt; needCheck = ThreadLocal.withInitial(() -&gt; false);
public ScriptingSandbox(ScriptEngine scriptEngine) throws InstantiationException {
this.scriptEngine = scriptEngine;
securityManager = new SecurityManager(){
//仅在需要的时候检查权限
@Override
public void checkPermission(Permission perm) {
if (needCheck.get() &amp;&amp; accessControlContext != null) {
super.checkPermission(perm, accessControlContext);
}
}
};
//设置执行脚本需要的权限
setPermissions(Arrays.asList(
new RuntimePermission(&quot;getProtectionDomain&quot;),
new PropertyPermission(&quot;jdk.internal.lambda.dumpProxyClasses&quot;,&quot;read&quot;),
new FilePermission(Shell.class.getProtectionDomain().getPermissions().elements().nextElement().getName(),&quot;read&quot;),
new RuntimePermission(&quot;createClassLoader&quot;),
new RuntimePermission(&quot;accessClassInPackage.jdk.internal.org.objectweb.*&quot;),
new RuntimePermission(&quot;accessClassInPackage.jdk.nashorn.internal.*&quot;),
new RuntimePermission(&quot;accessDeclaredMembers&quot;),
new ReflectPermission(&quot;suppressAccessChecks&quot;)
));
}
//设置执行上下文的权限
public void setPermissions(List&lt;Permission&gt; permissionCollection) {
Permissions perms = new Permissions();
if (permissionCollection != null) {
for (Permission p : permissionCollection) {
perms.add(p);
}
}
ProtectionDomain domain = new ProtectionDomain(new CodeSource(null, (CodeSigner[]) null), perms);
accessControlContext = new AccessControlContext(new ProtectionDomain[]{domain});
}
public Object eval(final String code) {
SecurityManager oldSecurityManager = System.getSecurityManager();
System.setSecurityManager(securityManager);
needCheck.set(true);
try {
//在AccessController的保护下执行脚本
return AccessController.doPrivileged((PrivilegedAction&lt;Object&gt;) () -&gt; {
try {
return scriptEngine.eval(code);
} catch (ScriptException e) {
e.printStackTrace();
}
return null;
}, accessControlContext);
} catch (Exception ex) {
log.error(&quot;抱歉,无法执行脚本 {}&quot;, code, ex);
} finally {
needCheck.set(false);
System.setSecurityManager(oldSecurityManager);
}
return null;
}
```
写一段测试代码使用刚才定义的ScriptingSandbox沙箱工具类来执行脚本
```
@GetMapping(&quot;right2&quot;)
public Object right2(@RequestParam(&quot;name&quot;) String name) throws InstantiationException {
//使用沙箱执行脚本
ScriptingSandbox scriptingSandbox = new ScriptingSandbox(jsEngine);
return scriptingSandbox.eval(String.format(&quot;var name='%s'; name=='admin'?1:0;&quot;, name));
}
```
这次,我们再使用之前的注入脚本调用这个接口:
```
http://localhost:45678/codeinject/right2?name=haha%27;java.lang.System.exit(0);%27
```
可以看到结果中抛出了AccessControlException异常注入攻击失效了
```
[13:09:36.080] [http-nio-45678-exec-1] [ERROR] [o.g.t.c.c.codeinject.ScriptingSandbox:77 ] - 抱歉,无法执行脚本 var name='haha';java.lang.System.exit(0);''; name=='admin'?1:0;
java.security.AccessControlException: access denied (&quot;java.lang.RuntimePermission&quot; &quot;exitVM.0&quot;)
at java.security.AccessControlContext.checkPermission(AccessControlContext.java:472)
at java.lang.SecurityManager.checkPermission(SecurityManager.java:585)
at org.geekbang.time.commonmistakes.codeanddata.codeinject.ScriptingSandbox$1.checkPermission(ScriptingSandbox.java:30)
at java.lang.SecurityManager.checkExit(SecurityManager.java:761)
at java.lang.Runtime.exit(Runtime.java:107)
```
在实际应用中,我们可以考虑同时使用这两种方法,确保代码执行的安全性。
## XSS必须全方位严防死堵
对于业务开发来说XSS的问题同样要引起关注。
XSS问题的根源在于原本是让用户传入或输入正常数据的地方被黑客替换为了JavaScript脚本页面没有经过转义直接显示了这个数据然后脚本就被执行了。更严重的是脚本没有经过转义就保存到了数据库中随后页面加载数据的时候数据中混入的脚本又当做代码执行了。黑客可以利用这个漏洞来盗取敏感数据诱骗用户访问钓鱼网站等。
我们写一段代码测试下。首先服务端定义两个接口其中index接口查询用户名信息返回给xss页面save接口使用@RequestParam注解接收用户名并创建用户保存到数据库然后重定向浏览器到index接口
```
@RequestMapping(&quot;xss&quot;)
@Slf4j
@Controller
public class XssController {
@Autowired
private UserRepository userRepository;
//显示xss页面
@GetMapping
public String index(ModelMap modelMap) {
//查数据库
User user = userRepository.findById(1L).orElse(new User());
//给View提供Model
modelMap.addAttribute(&quot;username&quot;, user.getName());
return &quot;xss&quot;;
}
//保存用户信息
@PostMapping
public String save(@RequestParam(&quot;username&quot;) String username, HttpServletRequest request) {
User user = new User();
user.setId(1L);
user.setName(username);
userRepository.save(user);
//保存完成后重定向到首页
return &quot;redirect:/xss/&quot;;
}
}
//用户类同时作为DTO和Entity
@Entity
@Data
public class User {
@Id
private Long id;
private String name;
}
```
我们使用Thymeleaf模板引擎来渲染页面。模板代码比较简单页面加载的时候会在标签显示用户名用户输入用户名提交后调用save接口创建用户
```
&lt;div style=&quot;font-size: 14px&quot;&gt;
&lt;form id=&quot;myForm&quot; method=&quot;post&quot; th:action=&quot;@{/xss/}&quot;&gt;
&lt;label th:utext=&quot;${username}&quot;/&gt;
&lt;input id=&quot;username&quot; name=&quot;username&quot; size=&quot;100&quot; type=&quot;text&quot;/&gt;
&lt;button th:text=&quot;Register&quot; type=&quot;submit&quot;/&gt;
&lt;/form&gt;
&lt;/div&gt;
```
打开xss页面后在文本框中输入&lt;script&gt;alert(test)&lt;/script&gt;点击Register按钮提交页面会弹出alert对话框
<img src="https://static001.geekbang.org/resource/image/cc/7f/cc50a56d83b3687859a396081346a47f.png" alt="">
<img src="https://static001.geekbang.org/resource/image/c4/71/c4633bc6edc93c98e1d27969f6518571.png" alt="">
并且,脚本被保存到了数据库:
<img src="https://static001.geekbang.org/resource/image/7e/bc/7ed8a0a92059149ed32bae43458307bc.png" alt="">
你可能想到了解决方式就是HTML转码。既然是通过@RequestParam来获取请求参数,那我们定义一个@InitBinder实现数据绑定的时候,对字符串进行转码即可:
```
@ControllerAdvice
public class SecurityAdvice {
@InitBinder
protected void initBinder(WebDataBinder binder) {
//注册自定义的绑定器
binder.registerCustomEditor(String.class, new PropertyEditorSupport() {
@Override
public String getAsText() {
Object value = getValue();
return value != null ? value.toString() : &quot;&quot;;
}
@Override
public void setAsText(String text) {
//赋值时进行HTML转义
setValue(text == null ? null : HtmlUtils.htmlEscape(text));
}
});
}
}
```
的确针对这个场景这种做法是可行的。数据库中保存了转义后的数据因此数据会被当做HTML显示在页面上而不是当做脚本执行
<img src="https://static001.geekbang.org/resource/image/5f/ca/5ff4c92a1571da41ccb804c4232171ca.png" alt="">
<img src="https://static001.geekbang.org/resource/image/88/01/88cedbd1557690157e52010280386801.png" alt="">
但是,这种处理方式犯了一个严重的错误,那就是没有从根儿上来处理安全问题。因为@InitBinder是Spring Web层面的处理逻辑如果有代码不通过@RequestParam来获取数据而是直接从HTTP请求获取数据的话这种方式就不会奏效。比如这样
```
user.setName(request.getParameter(&quot;username&quot;));
```
更合理的解决方式是定义一个servlet Filter通过HttpServletRequestWrapper实现servlet层面的统一参数替换
```
//自定义过滤器
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class XssFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
chain.doFilter(new XssRequestWrapper((HttpServletRequest) request), response);
}
}
public class XssRequestWrapper extends HttpServletRequestWrapper {
public XssRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String[] getParameterValues(String parameter) {
//获取多个参数值的时候对所有参数值应用clean方法逐一清洁
return Arrays.stream(super.getParameterValues(parameter)).map(this::clean).toArray(String[]::new);
}
@Override
public String getHeader(String name) {
//同样清洁请求头
return clean(super.getHeader(name));
}
@Override
public String getParameter(String parameter) {
//获取参数单一值也要处理
return clean(super.getParameter(parameter));
}
//clean方法就是对值进行HTML转义
private String clean(String value) {
return StringUtils.isEmpty(value)? &quot;&quot; : HtmlUtils.htmlEscape(value);
}
}
```
这样我们就可以实现所有请求参数的HTML转义了。不过这种方式还是不够彻底原因是无法处理通过@RequestBody注解提交的JSON数据。比如有这样一个PUT接口直接保存了客户端传入的JSON User对象
```
@PutMapping
public void put(@RequestBody User user) {
userRepository.save(user);
}
```
通过Postman请求这个接口保存到数据库中的数据还是没有转义
<img src="https://static001.geekbang.org/resource/image/6d/4f/6d8e2b3b68e8a623d039d9d73999a64f.png" alt="">
我们需要自定义一个Jackson反列化器来实现反序列化时的字符串的HTML转义
```
//注册自定义的Jackson反序列器
@Bean
public Module xssModule() {
SimpleModule module = new SimpleModule();
module.module.addDeserializer(String.class, new XssJsonDeserializer());
return module;
}
public class XssJsonDeserializer extends JsonDeserializer&lt;String&gt; {
@Override
public String deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException, JsonProcessingException {
String value = jsonParser.getValueAsString();
if (value != null) {
//对于值进行HTML转义
return HtmlUtils.htmlEscape(value);
}
return value;
}
@Override
public Class&lt;String&gt; handledType() {
return String.class;
}
}
```
这样就实现了既能转义Get/Post通过请求参数提交的数据又能转义请求体中直接提交的JSON数据。
你可能觉得做到这里我们的防范已经很全面了但其实不是。这种只能堵新漏确保新数据进入数据库之前转义。如果因为之前的漏洞数据库中已经保存了一些JavaScript代码那么读取的时候同样可能出问题。因此我们还要实现数据读取的时候也转义。
接下来,我们看一下具体的实现方式。
首先之前我们处理了JSON反序列化问题那么就需要同样处理序列化实现数据从数据库中读取的时候转义否则读出来的JSON可能包含JavaScript代码。
比如我们定义这样一个GET接口以JSON来返回用户信息
```
@GetMapping(&quot;user&quot;)
@ResponseBody
public User query() {
return userRepository.findById(1L).orElse(new User());
}
```
<img src="https://static001.geekbang.org/resource/image/b2/f8/b2f919307e42e79ce78622b305d455f8.png" alt="">
修改之前的SimpleModule加入自定义序列化器并且实现序列化时处理字符串转义
```
//注册自定义的Jackson序列器
@Bean
public Module xssModule() {
SimpleModule module = new SimpleModule();
module.addDeserializer(String.class, new XssJsonDeserializer());
module.addSerializer(String.class, new XssJsonSerializer());
return module;
}
public class XssJsonSerializer extends JsonSerializer&lt;String&gt; {
@Override
public Class&lt;String&gt; handledType() {
return String.class;
}
@Override
public void serialize(String value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
if (value != null) {
//对字符串进行HTML转义
jsonGenerator.writeString(HtmlUtils.htmlEscape(value));
}
}
}
```
可以看到这次读到的JSON也转义了
<img src="https://static001.geekbang.org/resource/image/31/fc/315f67193d1f9efe4b09db85361c53fc.png" alt="">
其次我们还需要处理HTML模板。对于Thymeleaf模板引擎需要注意的是使用th:utext来显示数据是不会进行转义的需要使用th:text
```
&lt;label th:text=&quot;${username}&quot;/&gt;
```
经过修改后即使数据库中已经保存了JavaScript代码呈现的时候也只能作为HTML显示了。现在对于进和出两个方向我们都实现了补漏。
所谓百密总有一疏。为了避免疏漏进一步控制XSS可能带来的危害我们还要考虑一种情况如果需要在Cookie中写入敏感信息的话我们可以开启HttpOnly属性。这样JavaScript代码就无法读取Cookie了即便页面被XSS注入了攻击代码也无法获得我们的Cookie。
写段代码测试一下。定义两个接口其中readCookie接口读取Key为test的CookiewriteCookie接口写入Cookie根据参数HttpOnly确定Cookie是否开启HttpOnly
```
//服务端读取Cookie
@GetMapping(&quot;readCookie&quot;)
@ResponseBody
public String readCookie(@CookieValue(&quot;test&quot;) String cookieValue) {
return cookieValue;
}
//服务端写入Cookie
@GetMapping(&quot;writeCookie&quot;)
@ResponseBody
public void writeCookie(@RequestParam(&quot;httpOnly&quot;) boolean httpOnly, HttpServletResponse response) {
Cookie cookie = new Cookie(&quot;test&quot;, &quot;zhuye&quot;);
//根据httpOnly入参决定是否开启HttpOnly属性
cookie.setHttpOnly(httpOnly);
response.addCookie(cookie);
}
```
可以看到由于test和_ga这两个Cookie不是HttpOnly的。通过document.cookie可以输出这两个Cookie的内容
<img src="https://static001.geekbang.org/resource/image/72/77/726e984d392aa1afc6d7371447700977.png" alt="">
为test这个Cookie启用了HttpOnly属性后就不能被document.cookie读取到了输出中只有_ga一项
<img src="https://static001.geekbang.org/resource/image/1b/0c/1b287474f0666d5a2fde8e9442ae2e0c.png" alt="">
但是服务端可以读取到这个cookie
<img src="https://static001.geekbang.org/resource/image/b2/bd/b25da8d4aa5778798652f9685a93f6bd.png" alt="">
## 重点回顾
今天我通过案例和你具体分析了SQL注入和XSS攻击这两类注入类安全问题。
在学习SQL注入的时候我们通过sqlmap工具看到了几种常用注入方式这可能改变了我们对SQL注入威力的认知对于POST请求、请求没有任何返回数据、请求不会出错的情况下仍然可以完成注入并可以导出数据库的所有数据。
对于SQL注入来说使用参数化的查询是最好的堵漏方式对于JdbcTemplate来说我们可以使用“?”作为参数占位符对于MyBatis来说我们需要使用“#{}”进行参数化处理。
和SQL注入类似的是脚本引擎动态执行代码需要确保外部传入的数据只能作为数据来处理不能和代码拼接在一起只能作为参数来处理。代码和数据之间需要划出清晰的界限否则可能产生代码注入问题。同时我们可以通过设置一个代码的执行沙箱来细化代码的权限这样即便产生了注入问题因为权限受限注入攻击也很难发挥威力。
**随后通过学习XSS案例我们认识到处理安全问题需要确保三点。**
- 第一,要从根本上、从最底层进行堵漏,尽量不要在高层框架层面做,否则堵漏可能不彻底。
- 第二,堵漏要同时考虑进和出,不仅要确保数据存入数据库的时候进行了转义或过滤,还要在取出数据呈现的时候再次转义,确保万无一失。
- 第三除了直接堵漏外我们还可以通过一些额外的手段限制漏洞的威力。比如为Cookie设置HttpOnly属性来防止数据被脚本读取又比如尽可能限制字段的最大保存长度即使出现漏洞也会因为长度问题限制黑客构造复杂攻击脚本的能力。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在讨论SQL注入案例时最后那次测试我们看到sqlmap返回了4种注入方式。其中布尔盲注、时间盲注和报错注入我都介绍过了。你知道联合查询注入是什么吗
1. 在讨论XSS的时候对于Thymeleaf模板引擎我们知道如何让文本进行HTML转义显示。FreeMarker也是Java中很常用的模板引擎你知道如何处理转义吗
你还遇到过其他类型的注入问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,621 @@
<audio id="audio" title="30 | 如何正确保存和传输敏感数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/d6/63978f5797af79797ed9fbbe6b1596d6.mp3"></audio>
你好,我是朱晔。
今天我们从安全角度来聊聊用户名、密码、身份证等敏感信息应该怎么保存和传输。同时你还可以进一步复习加密算法中的散列、对称加密和非对称加密算法以及HTTPS等相关知识。
## 应该怎样保存用户密码?
最敏感的数据恐怕就是用户的密码了。黑客一旦窃取了用户密码,或许就可以登录进用户的账号,消耗其资产、发布不良信息等;更可怕的是,有些用户至始至终都是使用一套密码,密码一旦泄露,就可以被黑客用来登录全网。
为了防止密码泄露,最重要的原则是不要保存用户密码。你可能会觉得很好笑,不保存用户密码,之后用户登录的时候怎么验证?其实,我指的是**不保存原始密码,这样即使拖库也不会泄露用户密码。**
我经常会听到大家说不要明文保存用户密码应该把密码通过MD5加密后保存。这的确是一个正确的方向但这个说法并不准确。
首先MD5其实不是真正的加密算法。所谓加密算法是可以使用密钥把明文加密为密文随后还可以使用密钥解密出明文是双向的。
而MD5是散列、哈希算法或者摘要算法。不管多长的数据使用MD5运算后得到的都是固定长度的摘要信息或指纹信息无法再解密为原始数据。所以MD5是单向的。**最重要的是仅仅使用MD5对密码进行摘要并不安全**。
比如使用如下代码在保持用户信息时对密码进行了MD5计算
```
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//密码字段使用MD5哈希后保存
userData.setPassword(DigestUtils.md5Hex(password));
return userRepository.save(userData);
```
通过输出可以看到密码是32位的MD5
```
&quot;password&quot;: &quot;325a2cc052914ceeb8c19016c091d2ac&quot;
```
到某MD5破解网站上输入这个MD5不到1秒就得到了原始密码
<img src="https://static001.geekbang.org/resource/image/e1/de/e1b3638dea64636494c3dcb0bb9b8ade.png" alt="">
其实你可以想一下虽然MD5不可解密但是我们可以构建一个超大的数据库把所有20位以内的数字和字母组合的密码全部计算一遍MD5存进去需要解密的时候搜索一下MD5就可以得到原始值了。这就是字典表。
目前有些MD5解密网站使用的是彩虹表是一种使用时间空间平衡的技术即可以使用更大的空间来降低破解时间也可以使用更长的破解时间来换取更小的空间。
**此外你可能会觉得多次MD5比较安全其实并不是这样**。比如如下代码使用两次MD5进行摘要
```
userData.setPassword(DigestUtils.md5Hex(DigestUtils.md5Hex( password)));
```
得到下面的MD5
```
&quot;password&quot;: &quot;ebbca84993fe002bac3a54e90d677d09&quot;
```
也可以破解出密码并且破解网站还告知我们这是两次MD5算法
<img src="https://static001.geekbang.org/resource/image/ce/b1/ce87f65a3289e50d4e29754073b7eab1.png" alt="">
所以直接保存MD5后的密码是不安全的。一些同学可能会说还需要加盐。是的但是加盐如果不当还是非常不安全比较重要的有两点。
第一,**不能在代码中写死盐,且盐需要有一定的长度**,比如这样:
```
userData.setPassword(DigestUtils.md5Hex(&quot;salt&quot; + password));
```
得到了如下MD5
```
&quot;password&quot;: &quot;58b1d63ed8492f609993895d6ba6b93a&quot;
```
对于这样一串MD5虽然破解网站上找不到原始密码但是黑客可以自己注册一个账号使用一个简单的密码比如1
```
&quot;password&quot;: &quot;55f312f84e7785aa1efa552acbf251db&quot;
```
然后再去破解网站试一下这个MD5就可以得到原始密码是salt也就知道了盐值是salt
<img src="https://static001.geekbang.org/resource/image/32/ca/321dfe5822da9fe186b17f283bda1fca.png" alt="">
其实,知道盐是什么没什么关系,关键的是我们是在代码里写死了盐,并且盐很短、所有用户都是这个盐。这么做有三个问题:
- 因为盐太短、太简单了如果用户原始密码也很简单那么整个拼起来的密码也很短这样一般的MD5破解网站都可以直接解密这个MD5除去盐就知道原始密码了。
- 相同的盐意味着使用相同密码的用户MD5值是一样的知道了一个用户的密码就可能知道了多个。
- 我们也可以使用这个盐来构建一张彩虹表,虽然会花不少代价,但是一旦构建完成,所有人的密码都可以被破解。
**所以最好是每一个密码都有独立的盐并且盐要长一点比如超过20位**
第二,**虽然说每个人的盐最好不同,但我也不建议将一部分用户数据作为盐。**比如,使用用户名作为盐:
```
userData.setPassword(DigestUtils.md5Hex(name + password));
```
如果世界上所有的系统都是按照这个方案来保存密码那么root、admin这样的用户使用再复杂的密码也总有一天会被破解因为黑客们完全可以针对这些常用用户名来做彩虹表。**所以,盐最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。**
正确的做法是使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如可以使用UUID作为盐把盐一起保存到数据库中
```
userData.setSalt(UUID.randomUUID().toString());
userData.setPassword(DigestUtils.md5Hex(userData.getSalt() + password));
```
并且每次用户修改密码的时候都重新计算盐,重新保存新的密码。你可能会问,盐保存在数据库中,那被拖库了不是就可以看到了吗?难道不应该加密保存吗?
在我看来,盐没有必要加密保存。盐的作用是,防止通过彩虹表快速实现密码“解密”,如果用户的盐都是唯一的,那么生成一次彩虹表只可能拿到一个用户的密码,这样黑客的动力会小很多。
**更好的做法是不要使用像MD5这样快速的摘要算法而是使用慢一点的算法**。比如Spring Security已经废弃了MessageDigestPasswordEncoder推荐使用BCryptPasswordEncoder也就是[BCrypt](https://en.wikipedia.org/wiki/Bcrypt)来进行密码哈希。BCrypt是为保存密码设计的算法相比MD5要慢很多。
写段代码来测试一下MD5以及使用不同代价因子的BCrypt看看哈希一次密码的耗时。
```
private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@GetMapping(&quot;performance&quot;)
public void performance() {
StopWatch stopWatch = new StopWatch();
String password = &quot;Abcd1234&quot;;
stopWatch.start(&quot;MD5&quot;);
//MD5
DigestUtils.md5Hex(password);
stopWatch.stop();
stopWatch.start(&quot;BCrypt(10)&quot;);
//代价因子为10的BCrypt
String hash1 = BCrypt.gensalt(10);
BCrypt.hashpw(password, hash1);
System.out.println(hash1);
stopWatch.stop();
stopWatch.start(&quot;BCrypt(12)&quot;);
//代价因子为12的BCrypt
String hash2 = BCrypt.gensalt(12);
BCrypt.hashpw(password, hash2);
System.out.println(hash2);
stopWatch.stop();
stopWatch.start(&quot;BCrypt(14)&quot;);
//代价因子为14的BCrypt
String hash3 = BCrypt.gensalt(14);
BCrypt.hashpw(password, hash3);
System.out.println(hash3);
stopWatch.stop();
log.info(&quot;{}&quot;, stopWatch.prettyPrint());
}
```
可以看到MD5只需要0.8毫秒而三次BCrypt哈希代价因子分别设置为10、12和14耗时分别是82毫秒、312毫秒和1.2秒:
<img src="https://static001.geekbang.org/resource/image/13/46/13241938861dd3ca9ba984776cc90846.png" alt="">
也就是说如果制作8位密码长度的MD5彩虹表需要5个月那么对于BCrypt来说可能就需要几十年大部分黑客应该都没有这个耐心。
我们写一段代码观察下BCryptPasswordEncoder生成的密码哈希的规律
```
@GetMapping(&quot;better&quot;)
public UserData better(@RequestParam(value = &quot;name&quot;, defaultValue = &quot;zhuye&quot;) String name, @RequestParam(value = &quot;password&quot;, defaultValue = &quot;Abcd1234&quot;) String password) {
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//保存哈希后的密码
userData.setPassword(passwordEncoder.encode(password));
userRepository.save(userData);
//判断密码是否匹配
log.info(&quot;match ? {}&quot;, passwordEncoder.matches(password, userData.getPassword()));
return userData;
}
```
我们可以发现三点规律。
第一我们调用encode、matches方法进行哈希、做密码比对的时候不需要传入盐。**BCrypt把盐作为了算法的一部分强制我们遵循安全保存密码的最佳实践。**
第二,生成的盐和哈希后的密码拼在了一起:`$`是字段分隔符,其中第一个`$`后的2a代表算法版本第二个`$`后的10是代价因子默认是10代表2的10次方次哈希第三个`$`后的22个字符是盐再后面是摘要。所以说我们不需要使用单独的数据库字段来保存盐。
```
&quot;password&quot;: &quot;$2a$10$wPWdQwfQO2lMxqSIb6iCROXv7lKnQq5XdMO96iCYCj7boK9pk6QPC&quot;
//格式为:$&lt;ver&gt;$&lt;cost&gt;$&lt;salt&gt;&lt;digest&gt;
```
第三代价因子的值越大BCrypt哈希的耗时越久。因此对于代价因子的值更建议的实践是根据用户的忍耐程度和硬件设置一个尽可能大的值。
最后,我们需要注意的是,虽然黑客已经很难通过彩虹表来破解密码了,但是仍然有可能暴力破解密码,也就是对于同一个用户名使用常见的密码逐一尝试登录。因此,除了做好密码哈希保存的工作外,我们还要建设一套完善的安全防御机制,在感知到暴力破解危害的时候,开启短信验证、图形验证码、账号暂时锁定等防御机制来抵御暴力破解。
## 应该怎么保存姓名和身份证?
我们把姓名和身份证,叫做二要素。
现在互联网非常发达,很多服务都可以在网上办理,很多网站仅仅依靠二要素来确认你是谁。所以,二要素是比较敏感的数据,如果在数据库中明文保存,那么数据库被攻破后,黑客就可能拿到大量的二要素信息。如果这些二要素被用来申请贷款等,后果不堪设想。
之前我们提到的单向散列算法,显然不适合用来加密保存二要素,因为数据无法解密。这个时候,我们需要选择真正的加密算法。可供选择的算法,包括对称加密和非对称加密算法两类。
对称加密算法,是使用相同的密钥进行加密和解密。使用对称加密算法来加密双方的通信的话,双方需要先约定一个密钥,加密方才能加密,接收方才能解密。如果密钥在发送的时候被窃取,那么加密就是白忙一场。因此,这种加密方式的特点是,加密速度比较快,但是密钥传输分发有泄露风险。
非对称加密算法,或者叫公钥密码算法。公钥密码是由一对密钥对构成的,使用公钥或者说加密密钥来加密,使用私钥或者说解密密钥来解密,公钥可以任意公开,私钥不能公开。使用非对称加密的话,通信双方可以仅分享公钥用于加密,加密后的数据没有私钥无法解密。因此,这种加密方式的特点是,加密速度比较慢,但是解决了密钥的配送分发安全问题。
但是,对于保存敏感信息的场景来说,加密和解密都是我们的服务端程序,不太需要考虑密钥的分发安全性,也就是说使用非对称加密算法没有太大的意义。在这里,我们使用对称加密算法来加密数据。
接下来我就重点与你说说对称加密算法。对称加密常用的加密算法有DES、3DES和AES。
虽然现在仍有许多老项目使用了DES算法但我不推荐使用。在1999年的DES挑战赛3中DES密码破解耗时不到一天而现在DES密码破解更快使用DES来加密数据非常不安全。因此**在业务代码中要避免使用DES加密**。
而3DES算法是使用不同的密钥进行三次DES串联调用虽然解决了DES不够安全的问题但是比AES慢也不太推荐。
AES是当前公认的比较安全兼顾性能的对称加密算法。不过严格来说AES并不是实际的算法名称而是算法标准。2000年NIST选拔出Rijndael算法作为AES的标准。
AES有一个重要的特点就是分组加密体制一次只能处理128位的明文然后生成128位的密文。如果要加密很长的明文那么就需要迭代处理而迭代方式就叫做模式。网上很多使用AES来加密的代码使用的是最简单的ECB模式也叫电子密码本模式其基本结构如下
<img src="https://static001.geekbang.org/resource/image/27/8b/27c2534caeefcac4a5dd1a2814957d8b.png" alt="">
可以看到,这种结构有两个风险:明文和密文是一一对应的,如果明文中有重复的分组,那么密文中可以观察到重复,掌握密文的规律;因为每一个分组是独立加密和解密的 ,如果密文分组的顺序,也可以反过来操纵明文,那么就可以实现不解密密文的情况下,来修改明文。
我们写一段代码来测试下。在下面的代码中我们使用ECB模式测试
- 加密一段包含16个字符的字符串得到密文A然后把这段字符串复制一份成为一个32个字符的字符串再进行加密得到密文B。我们验证下密文B是不是重复了一遍的密文A。
- 模拟银行转账的场景,假设整个数据由发送方账号、接收方账号、金额三个字段构成。我们尝试改变密文中数据的顺序来操纵明文。
```
private static final String KEY = &quot;secretkey1234567&quot;; //密钥
//测试ECB模式
@GetMapping(&quot;ecb&quot;)
public void ecb() throws Exception {
Cipher cipher = Cipher.getInstance(&quot;AES/ECB/NoPadding&quot;);
test(cipher, null);
}
//获取加密秘钥帮助方法
private static SecretKeySpec setKey(String secret) {
return new SecretKeySpec(secret.getBytes(), &quot;AES&quot;);
}
//测试逻辑
private static void test(Cipher cipher, AlgorithmParameterSpec parameterSpec) throws Exception {
//初始化Cipher
cipher.init(Cipher.ENCRYPT_MODE, setKey(KEY), parameterSpec);
//加密测试文本
System.out.println(&quot;一次:&quot; + Hex.encodeHexString(cipher.doFinal(&quot;abcdefghijklmnop&quot;.getBytes())));
//加密重复一次的测试文本
System.out.println(&quot;两次:&quot; + Hex.encodeHexString(cipher.doFinal(&quot;abcdefghijklmnopabcdefghijklmnop&quot;.getBytes())));
//下面测试是否可以通过操纵密文来操纵明文
//发送方账号
byte[] sender = &quot;1000000000012345&quot;.getBytes();
//接收方账号
byte[] receiver = &quot;1000000000034567&quot;.getBytes();
//转账金额
byte[] money = &quot;0000000010000000&quot;.getBytes();
//加密发送方账号
System.out.println(&quot;发送方账号:&quot; + Hex.encodeHexString(cipher.doFinal(sender)));
//加密接收方账号
System.out.println(&quot;接收方账号:&quot; + Hex.encodeHexString(cipher.doFinal(receiver)));
//加密金额
System.out.println(&quot;金额:&quot; + Hex.encodeHexString(cipher.doFinal(money)));
//加密完整的转账信息
byte[] result = cipher.doFinal(ByteUtils.concatAll(sender, receiver, money));
System.out.println(&quot;完整数据:&quot; + Hex.encodeHexString(result));
//用于操纵密文的临时字节数组
byte[] hack = new byte[result.length];
//把密文前两段交换
System.arraycopy(result, 16, hack, 0, 16);
System.arraycopy(result, 0, hack, 16, 16);
System.arraycopy(result, 32, hack, 32, 16);
cipher.init(Cipher.DECRYPT_MODE, setKey(KEY), parameterSpec);
//尝试解密
System.out.println(&quot;原始明文:&quot; + new String(ByteUtils.concatAll(sender, receiver, money)));
System.out.println(&quot;操纵密文:&quot; + new String(cipher.doFinal(hack)));
}
```
输出如下:
<img src="https://static001.geekbang.org/resource/image/cd/59/cd506b4cf8a020d4b6077fdfa3b34959.png" alt="">
可以看到:
- 两个相同明文分组产生的密文,就是两个相同的密文分组叠在一起。
- 在不知道密钥的情况下,我们操纵密文实现了对明文数据的修改,对调了发送方账号和接收方账号。
所以说,**ECB模式虽然简单但是不安全不推荐使用**。我们再看一下另一种常用的加密模式CBC模式。
CBC模式在解密或解密之前引入了XOR运算第一个分组使用外部提供的初始化向量IV从第二个分组开始使用前一个分组的数据这样即使明文是一样的加密后的密文也是不同的并且分组的顺序不能任意调换。这就解决了ECB模式的缺陷
<img src="https://static001.geekbang.org/resource/image/79/e8/7955a199e2400adc7ac7577b3712bae8.png" alt="">
我们把之前的代码修改为CBC模式再次进行测试
```
private static final String initVector = &quot;abcdefghijklmnop&quot;; //初始化向量
@GetMapping(&quot;cbc&quot;)
public void cbc() throws Exception {
Cipher cipher = Cipher.getInstance(&quot;AES/CBC/NoPadding&quot;);
IvParameterSpec iv = new IvParameterSpec(initVector.getBytes(&quot;UTF-8&quot;));
test(cipher, iv);
}
```
可以看到,相同的明文字符串复制一遍得到的密文并不是重复两个密文分组,并且调换密文分组的顺序无法操纵明文:
<img src="https://static001.geekbang.org/resource/image/8b/08/8b79074d6533a84c32e48eab3daef808.png" alt="">
其实除了ECB模式和CBC模式外AES算法还有CFB、OFB、CTR模式你可以参考[这里](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation)了解它们的区别。《实用密码学》一书比较推荐的是CBC和CTR模式。还需要注意的是ECB和CBC模式还需要设置合适的填充模式才能处理超过一个分组的数据。
对于敏感数据保存除了选择AES+合适模式进行加密外,我还推荐以下几个实践:
- 不要在代码中写死一个固定的密钥和初始化向量,最好和之前提到的盐一样,是唯一、独立并且每次都变化的。
- 推荐使用独立的加密服务来管控密钥、做加密操作,千万不要把密钥和密文存在一个数据库,加密服务需要设置非常高的管控标准。
- 数据库中不能保存明文的敏感信息,但可以保存脱敏的信息。普通查询的时候,直接查脱敏信息即可。
接下来,我们按照这个策略完成相关代码实现。
第一步对于用户姓名和身份证我们分别保存三个信息脱敏后的明文、密文和加密ID。加密服务加密后返回密文和加密ID随后使用加密ID来请求加密服务进行解密
```
@Data
@Entity
public class UserData {
@Id
private Long id;
private String idcard;//脱敏的身份证
private Long idcardCipherId;//身份证加密ID
private String idcardCipherText;//身份证密文
private String name;//脱敏的姓名
private Long nameCipherId;//姓名加密ID
private String nameCipherText;//姓名密文
}
```
第二步加密服务数据表保存加密ID、初始化向量和密钥。加密服务表中没有密文实现了密文和密钥分离保存
```
@Data
@Entity
public class CipherData {
@Id
@GeneratedValue(strategy = AUTO)
private Long id;
private String iv;//初始化向量
private String secureKey;//密钥
}
```
第三步加密服务使用GCM模式 Galois/Counter Mode的AES-256对称加密算法也就是AES-256-GCM。
这是一种[AEAD](https://tools.ietf.org/html/rfc5116)Authenticated Encryption with Associated Data认证加密算法除了能实现普通加密算法提供的保密性之外还能实现可认证性和密文完整性是目前最推荐的AES模式。
使用类似GCM的AEAD算法进行加解密除了需要提供初始化向量和密钥之外还可以提供一个AAD附加认证数据additional authenticated data用于验证未包含在明文中的附加信息解密时不使用加密时的AAD将解密失败。其实GCM模式的内部使用的就是CTR模式只不过还使用了GMAC签名算法对密文进行签名实现完整性校验。
接下来我们实现基于AES-256-GCM的加密服务包含下面的主要逻辑
- 加密时允许外部传入一个AAD用于认证加密服务每次都会使用新生成的随机值作为密钥和初始化向量。
- 在加密后加密服务密钥和初始化向量保存到数据库中返回加密ID作为本次加密的标识。
- 应用解密时需要提供加密ID、密文和加密时的AAD来解密。加密服务使用加密ID从数据库查询出密钥和初始化向量。
这段逻辑的实现代码比较长,我加了详细注释方便你仔细阅读:
```
@Service
public class CipherService {
//密钥长度
public static final int AES_KEY_SIZE = 256;
//初始化向量长度
public static final int GCM_IV_LENGTH = 12;
//GCM身份认证Tag长度
public static final int GCM_TAG_LENGTH = 16;
@Autowired
private CipherRepository cipherRepository;
//内部加密方法
public static byte[] doEncrypt(byte[] plaintext, SecretKey key, byte[] iv, byte[] aad) throws Exception {
//加密算法
Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
//Key规范
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), &quot;AES&quot;);
//GCM参数规范
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
//加密模式
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec);
//设置aad
if (aad != null)
cipher.updateAAD(aad);
//加密
byte[] cipherText = cipher.doFinal(plaintext);
return cipherText;
}
//内部解密方法
public static String doDecrypt(byte[] cipherText, SecretKey key, byte[] iv, byte[] aad) throws Exception {
//加密算法
Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
//Key规范
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), &quot;AES&quot;);
//GCM参数规范
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
//解密模式
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
//设置aad
if (aad != null)
cipher.updateAAD(aad);
//解密
byte[] decryptedText = cipher.doFinal(cipherText);
return new String(decryptedText);
}
//加密入口
public CipherResult encrypt(String data, String aad) throws Exception {
//加密结果
CipherResult encryptResult = new CipherResult();
//密钥生成器
KeyGenerator keyGenerator = KeyGenerator.getInstance(&quot;AES&quot;);
//生成密钥
keyGenerator.init(AES_KEY_SIZE);
SecretKey key = keyGenerator.generateKey();
//IV数据
byte[] iv = new byte[GCM_IV_LENGTH];
//随机生成IV
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
//处理aad
byte[] aaddata = null;
if (!StringUtils.isEmpty(aad))
aaddata = aad.getBytes();
//获得密文
encryptResult.setCipherText(Base64.getEncoder().encodeToString(doEncrypt(data.getBytes(), key, iv, aaddata)));
//加密上下文数据
CipherData cipherData = new CipherData();
//保存IV
cipherData.setIv(Base64.getEncoder().encodeToString(iv));
//保存密钥
cipherData.setSecureKey(Base64.getEncoder().encodeToString(key.getEncoded()));
cipherRepository.save(cipherData);
//返回本地加密ID
encryptResult.setId(cipherData.getId());
return encryptResult;
}
//解密入口
public String decrypt(long cipherId, String cipherText, String aad) throws Exception {
//使用加密ID找到加密上下文数据
CipherData cipherData = cipherRepository.findById(cipherId).orElseThrow(() -&gt; new IllegalArgumentException(&quot;invlaid cipherId&quot;));
//加载密钥
byte[] decodedKey = Base64.getDecoder().decode(cipherData.getSecureKey());
//初始化密钥
SecretKey originalKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, &quot;AES&quot;);
//加载IV
byte[] decodedIv = Base64.getDecoder().decode(cipherData.getIv());
//处理aad
byte[] aaddata = null;
if (!StringUtils.isEmpty(aad))
aaddata = aad.getBytes();
//解密
return doDecrypt(Base64.getDecoder().decode(cipherText.getBytes()), originalKey, decodedIv, aaddata);
}
}
```
第四步,分别实现加密和解密接口用于测试。
我们可以让用户选择如果需要保护二要素的话就自己输入一个查询密码作为AAD。系统需要读取用户敏感信息的时候还需要用户提供这个密码否则无法解密。这样一来即使黑客拿到了用户数据库的密文、加密服务的密钥和IV也会因为缺少AAD无法解密
```
@Autowired
private CipherService cipherService;
//加密
@GetMapping(&quot;right&quot;)
public UserData right(@RequestParam(value = &quot;name&quot;, defaultValue = &quot;朱晔&quot;) String name,
@RequestParam(value = &quot;idcard&quot;, defaultValue = &quot;300000000000001234&quot;) String idCard,
@RequestParam(value = &quot;aad&quot;, required = false)String aad) throws Exception {
UserData userData = new UserData();
userData.setId(1L);
//脱敏姓名
userData.setName(chineseName(name));
//脱敏身份证
userData.setIdcard(idCard(idCard));
//加密姓名
CipherResult cipherResultName = cipherService.encrypt(name,aad);
userData.setNameCipherId(cipherResultName.getId());
userData.setNameCipherText(cipherResultName.getCipherText());
//加密身份证
CipherResult cipherResultIdCard = cipherService.encrypt(idCard,aad);
userData.setIdcardCipherId(cipherResultIdCard.getId());
userData.setIdcardCipherText(cipherResultIdCard.getCipherText());
return userRepository.save(userData);
}
//解密
@GetMapping(&quot;read&quot;)
public void read(@RequestParam(value = &quot;aad&quot;, required = false)String aad) throws Exception {
//查询用户信息
UserData userData = userRepository.findById(1L).get();
//使用AAD来解密姓名和身份证
log.info(&quot;name : {} idcard : {}&quot;,
cipherService.decrypt(userData.getNameCipherId(), userData.getNameCipherText(),aad),
cipherService.decrypt(userData.getIdcardCipherId(), userData.getIdcardCipherText(),aad));
}
//脱敏身份证
private static String idCard(String idCard) {
String num = StringUtils.right(idCard, 4);
return StringUtils.leftPad(num, StringUtils.length(idCard), &quot;*&quot;);
}
//脱敏姓名
public static String chineseName(String chineseName) {
String name = StringUtils.left(chineseName, 1);
return StringUtils.rightPad(name, StringUtils.length(chineseName), &quot;*&quot;);
```
访问加密接口获得如下结果,可以看到数据库表中只有脱敏数据和密文:
```
{&quot;id&quot;:1,&quot;name&quot;:&quot;朱*&quot;,&quot;idcard&quot;:&quot;**************1234&quot;,&quot;idcardCipherId&quot;:26346,&quot;idcardCipherText&quot;:&quot;t/wIh1XTj00wJP1Lt3aGzSvn9GcqQWEwthN58KKU4KZ4Tw==&quot;,&quot;nameCipherId&quot;:26347,&quot;nameCipherText&quot;:&quot;+gHrk1mWmveBMVUo+CYon8Zjj9QAtw==&quot;}
```
访问解密接口,可以看到解密成功了:
```
[21:46:00.079] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.s.s.StoreIdCardController:102 ] - name : 朱晔 idcard : 300000000000001234
```
如果AAD输入不对会得到如下异常
```
javax.crypto.AEADBadTagException: Tag mismatch!
at com.sun.crypto.provider.GaloisCounterMode.decryptFinal(GaloisCounterMode.java:578)
at com.sun.crypto.provider.CipherCore.finalNoPadding(CipherCore.java:1116)
at com.sun.crypto.provider.CipherCore.fillOutputBuffer(CipherCore.java:1053)
at com.sun.crypto.provider.CipherCore.doFinal(CipherCore.java:853)
at com.sun.crypto.provider.AESCipher.engineDoFinal(AESCipher.java:446)
at javax.crypto.Cipher.doFinal(Cipher.java:2164)
```
经过这样的设计二要素就比较安全了。黑客要查询用户二要素的话需要同时拿到密文、IV+密钥、AAD。而这三者可能由三方掌管要全部拿到比较困难。
## 用一张图说清楚HTTPS
我们知道HTTP协议传输数据使用的是明文。那在传输敏感信息的场景下如果客户端和服务端中间有一个黑客作为中间人拦截请求就可以窃听到这些数据还可以修改客户端传过来的数据。这就是很大的安全隐患。
为解决这个安全隐患有了HTTPS协议。HTTPS=SSL/TLS+HTTP通过使用一系列加密算法来确保信息安全传输以实现数据传输的机密性、完整性和权威性。
- 机密性:使用非对称加密来加密密钥,然后使用密钥来加密数据,既安全又解决了非对称加密大量数据慢的问题。你可以做一个实验来测试两者的差距。
- 完整性:使用散列算法对信息进行摘要,确保信息完整无法被中间人篡改。
- 权威性:使用数字证书,来确保我们是在和合法的服务端通信。
可以看出理解HTTPS的流程将有助于我们理解各种加密算法的区别以及证书的意义。此外SSL/TLS还是混合加密系统的一个典范如果你需要自己开发应用层数据加密系统也可以参考它的流程。
那么我们就来看看HTTPS TLS 1.2连接RSA握手的整个过程吧。
<img src="https://static001.geekbang.org/resource/image/98/7c/982510795a50e4b18808eed81dac647c.png" alt="">
作为准备工作网站管理员需要申请并安装CA证书到服务端。CA证书中包含非对称加密的公钥、网站域名等信息密钥是服务端自己保存的不会在任何地方公开。
建立HTTPS连接的过程首先是TCP握手然后是TLS握手的一系列工作包括
1. 客户端告知服务端自己支持的密码套件比如TLS_RSA_WITH_AES_256_GCM_SHA384其中RSA是密钥交换的方式AES_256_GCM是加密算法SHA384是消息验证摘要算法提供客户端随机数。
1. 服务端应答选择的密码套件,提供服务端随机数。
1. 服务端发送CA证书给客户端客户端验证CA证书后面详细说明
1. 客户端生成PreMasterKey并使用非对称加密+公钥加密PreMasterKey。
1. 客户端把加密后的PreMasterKey传给服务端。
1. 服务端使用非对称加密+私钥解密得到PreMasterKey并使用PreMasterKey+两个随机数生成MasterKey。
1. 客户端也使用PreMasterKey+两个随机数生成MasterKey。
1. 客户端告知服务端之后将进行加密传输。
1. 客户端使用MasterKey配合对称加密算法进行对称加密测试。
1. 服务端也使用MasterKey配合对称加密算法进行对称加密测试。
接下来客户端和服务端的所有通信都是加密通信并且数据通过签名确保无法篡改。你可能会问客户端怎么验证CA证书呢
其实CA证书是一个证书链你可以看一下上图的左边部分
- 从服务端拿到的CA证书是用户证书我们需要通过证书中的签发人信息找到上级中间证书再网上找到根证书。
- 根证书只有为数不多的权威机构才能生成一般预置在OS中根本无法伪造。
- 找到根证书后,提取其公钥来验证中间证书的签名,判断其权威性。
- 最后再拿到中间证书的公钥,验证用户证书的签名。
这,就验证了用户证书的合法性,然后再校验其有效期、域名等信息进一步验证有效性。
总结一下TLS通过巧妙的流程和算法搭配解决了传输安全问题使用对称加密加密数据使用非对称加密算法确保密钥无法被中间人解密使用CA证书链认证确保中间人无法伪造自己的证书和公钥。
如果网站涉及敏感数据的传输必须使用HTTPS协议。作为用户如果你看到网站不是HTTPS的或者看到无效证书警告也不应该继续使用这个网站以免敏感信息被泄露。
## 重点回顾
今天,我们一起学习了如何保存和传输敏感数据。我来带你回顾一下重点内容。
对于数据保存,你需要记住两点:
- 用户密码不能加密保存更不能明文保存需要使用全球唯一的、具有一定长度的、随机的盐配合单向散列算法保存。使用BCrypt算法是一个比较好的实践。
- 诸如姓名和身份证这种需要可逆解密查询的敏感信息,需要使用对称加密算法保存。我的建议是,把脱敏数据和密文保存在业务数据库,独立使用加密服务来做数据加解密;对称加密需要用到的密钥和初始化向量,可以和业务数据库分开保存。
对于数据传输则务必通过SSL/TLS进行传输。对于用于客户端到服务端传输数据的HTTP我们需要使用基于SSL/TLS的HTTPS。对于一些走TCP的RPC服务同样可以使用SSL/TLS来确保传输安全。
最后,我要提醒你的是,如果不确定应该如何实现加解密方案或流程,可以咨询公司内部的安全专家,或是参考业界各大云厂商的方案,切勿自己想当然地去设计流程,甚至创造加密算法。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 虽然我们把用户名和密码脱敏加密保存在数据库中,但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面,对日志进行脱敏吗?
1. 你知道HTTPS双向认证的目的是什么吗流程上又有什么区别呢
关于各种加密算法,你还遇到过什么坑吗?你又是如何保存敏感数据的呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="答疑篇:安全篇思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/d9/304913fee45ec6b5390af039b20fccd9.mp3"></audio>
你好,我是朱晔。
今天我们继续一起分析这门课“安全篇”模块的第27~30讲的课后思考题。这些题目涉及了数据源头、安全兜底、数据和代码、敏感数据相关的4大知识点。
接下来,我们就一一具体分析吧。
### [27 | 数据源头:任何客户端的东西都不可信任](https://time.geekbang.org/column/article/235700)
**问题1**在讲述用户标识不能从客户端获取这个要点的时候我提到开发同学可能会因为用户信息未打通而通过前端来传用户ID。那我们有什么好办法来打通不同的系统甚至不同网站的用户标识吗
答:打通用户在不同系统之间的登录,大致有以下三种方案。
第一种把用户身份放在统一的服务端每一个系统都需要到这个服务端来做登录状态的确认确认后在自己网站的Cookie中保存会话这就是单点登录的做法。这种方案要求所有关联系统都对接一套中央认证服务器中央保存用户会话在未登录的时候跳转到中央认证服务器进行登录或登录状态确认。因此这种方案适合一个公司内部的不同域名下的网站。
第二种把用户身份信息直接放在Token中在客户端任意传递Token由服务端进行校验如果共享密钥话甚至不需要同一个服务端进行校验无需采用中央认证服务器相对比较松耦合典型的标准是JWT。这种方案适合异构系统的跨系统用户认证打通而且相比单点登录的方案用户体验会更好一些。
第三种如果需要打通不同公司系统的用户登录状态那么一般都会采用OAuth 2.0的标准中的授权码模式,基本流程如下:
1. 第三方网站客户端转到授权服务器上送ClientID、重定向地址RedirectUri等信息。
1. 用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)。
1. 授权完成后,重定向回到之前客户端提供的重定向地址,附上授权码。
1. 第三方网站服务端通过授权码+ClientID+ClientSecret去授权服务器换取Token。这里的Token包含访问Token和刷新Token访问Token过期后用刷新Token去获得新的访问Token。
因为我们不会对外暴露ClientSecret也不会对外暴露访问Token同时使用授权码换取Token的过程是服务端进行的客户端拿到的只是一次性的授权码所以这种模式比较安全。
**问题2**还有一类和客户端数据相关的漏洞非常重要那就是URL地址中的数据。在把匿名用户重定向到登录页面的时候我们一般会带上redirectUrl这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接由真实的网站+钓鱼的redirectUrl构成发邮件诱导用户进行登录。用户登录时访问的其实是真的网站所以不容易察觉到redirectUrl是钓鱼网站登录后却来到了钓鱼网站用户可能会不知不觉就把重要信息泄露了。这种安全问题我们叫做开放重定向问题。你觉得从代码层面应该怎么预防开放重定向问题呢
答:要从代码层面预防开放重定向问题,有以下三种做法可供参考:
- 第一种固定重定向的目标URL。
- 第二种可采用编号方式指定重定向的目标URL也就是重定向的目标URL只能是在我们的白名单内的。
- 第三种,用合理充分的校验方式来校验跳转的目标地址,如果是非己方地址,就告知用户跳转有风险,小心钓鱼网站的威胁。
### [28 | 安全兜底:涉及钱时,必须考虑防刷、限量和防重](https://time.geekbang.org/column/article/237060)
**问题1**防重、防刷都是事前手段,如果我们的系统正在被攻击或利用,你有什么办法及时发现问题吗?
答:对于及时发现系统正在被攻击或利用,监控是较好的手段,关键点在于报警阈值怎么设置。我觉得可以对比昨天同时、上周同时的量,发现差异达到一定百分比报警,而且报警需要有升级机制。此外,有的时候大盘很大的话,活动给整个大盘带来的变化不明显,如果进行整体监控可能出了问题也无法及时发现,因此可以考虑对于活动做独立的监控报警。
**问题2**任何三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量,你觉得一般是什么问题引起的呢?
答:我之前遇到的情况是,在事务内调用外部接口,调用超时后本地事务回滚本地就没有留下数据。更合适的做法是:
1. 请求发出之前先记录请求数据提交事务,记录状态为未知。
1. 发布调用外部接口的请求,如果可以拿到明确的结果,则更新数据库中记录的状态为成功或失败。如果出现超时或未知异常,不能假设第三方接口调用失败,需要通过查询接口查询明确的结果。
1. 写一个定时任务补偿数据库中所有未知状态的记录,从第三方接口同步结果。
值得注意的是对账的时候一定要对两边不管哪方数据缺失都可能是因为程序逻辑有bug需要重视。此外任何涉及第三方系统的交互都建议在数据库中保持明细的请求/响应报文方便在出问题的时候定位Bug根因。
### [29 | 数据和代码:数据就是数据,代码就是代码](https://time.geekbang.org/column/article/237139)
**问题1**在讨论SQL注入案例时最后那次测试我们看到sqlmap返回了4种注入方式。其中布尔盲注、时间盲注和报错注入我都介绍过了。你知道联合查询注入是什么吗
联合查询注入也就是通过UNION来实现我们需要的信息露出一般属于回显的注入方式。我们知道UNION可以用于合并两个SELECT查询的结果集因此可以把注入脚本来UNION到原始的SELECT后面。这样就可以查询我们需要的数据库元数据以及表数据了。
注入的关键点在于:
- 第一UNION的两个SELECT语句的列数和字段类型需要一致。
- 第二需要探查UNION后的结果和页面回显呈现数据的对应关系。
**问题2**在讨论XSS的时候对于Thymeleaf模板引擎我们知道如何让文本进行HTML转义显示。FreeMarker也是Java中很常用的模板引擎你知道如何处理转义吗
其实现在大多数的模板引擎都使用了黑名单机制而不是白名单机制来做HTML转义这样更能有效防止XSS漏洞。也就是默认开启HTML转义如果某些情况你不需要转义可以临时关闭。
比如,[FreeMarker](https://freemarker.apache.org/docs/dgui_misc_autoescaping.html)2.3.24以上版本默认对HTML、XHTML、XML等文件类型输出格式设置了各种转义规则你可以使用?no_esc
```
&lt;#-- 假设默认是HTML输出 --&gt;
${'&lt;b&gt;test&lt;/b&gt;'} &lt;#-- 输出: &amp;lt;b&amp;gt;test&amp;lt;/b&amp;gt; --&gt;
${'&lt;b&gt;test&lt;/b&gt;'?no_esc} &lt;#-- 输出: &lt;b&gt;test&lt;/b&gt; --&gt;
```
或noautoesc指示器
```
${'&amp;'} &lt;#-- 输出: &amp;amp; --&gt;
&lt;#noautoesc&gt;
${'&amp;'} &lt;#-- 输出: &amp; --&gt;
...
${'&amp;'} &lt;#-- 输出: &amp; --&gt;
&lt;/#noautoesc&gt;
${'&amp;'} &lt;#-- 输出: &amp;amp; --&gt;
```
来临时关闭转义。又比如,对于模板引擎[Mustache](https://mustache.github.io/mustache.5.html),可以使用三个花括号而不是两个花括号,来取消变量自动转义:
```
模板:
* {{name}}
* {{company}}
* {{{company}}}
数据:
{
&quot;name&quot;: &quot;Chris&quot;,
&quot;company&quot;: &quot;&lt;b&gt;GitHub&lt;/b&gt;&quot;
}
输出:
* Chris
*
* &amp;lt;b&amp;gt;GitHub&amp;lt;/b&amp;gt;
* &lt;b&gt;GitHub&lt;/b&gt;
```
### [30 | 如何正确保存和传输敏感数据?](https://time.geekbang.org/column/article/239150)
**问题1**虽然我们把用户名和密码脱敏加密保存在数据库中,但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面,对日志进行脱敏吗?
如果我们希望在日志的源头进行脱敏那么可以在日志框架层面做。比如对于logback日志框架我们可以自定义MessageConverter通过正则表达式匹配敏感信息脱敏。
需要注意的是,这种方式有两个缺点。
第一,正则表达式匹配敏感信息的格式不一定精确,会出现误杀漏杀的现象。一般来说,这个问题不会很严重。要实现精确脱敏的话,就只能提供各种脱敏工具类,然后让业务应用在日志中记录敏感信息的时候,先手动调用工具类进行脱敏。
第二如果数据量比较大的话脱敏操作可能会增加业务应用的CPU和内存使用甚至会导致应用不堪负荷出现不可用。考虑到目前大部分公司都引入了ELK来集中收集日志并且一般而言都不允许上服务器直接看文件日志因此我们可以考虑在日志收集中间件中比如logstash写过滤器进行脱敏。这样可以把脱敏的消耗转义到ELK体系中不过这种方式同样有第一点提到的字段不精确匹配导致的漏杀误杀的缺点。
**问题2**你知道HTTPS双向认证的目的是什么吗流程上又有什么区别呢
单向认证一般用于Web网站浏览器只需要验证服务端的身份。对于移动端App如果我们希望有更高的安全性可以引入HTTPS双向认证也就是除了客户端验证服务端身份之外服务端也验证客户端的身份。
单向认证和双向认证的流程区别,主要包括以下三个方面。
第一不仅仅服务端需要有CA证书客户端也需要有CA证书。
第二双向认证的流程中客户端校验服务端CA证书之后客户端会把自己的CA证书发给服务端然后服务端需要校验客户端CA证书的真实性。
第三客户端给服务端的消息会使用自己的私钥签名服务端可以使用客户端CA证书中的公钥验签。
这里还想补充一点对于移动应用程序考虑到更强的安全性我们一般也会把服务端的公钥配置在客户端中这种方式的叫做SSL Pinning。也就是说由客户端直接校验服务端证书的合法性而不是通过证书信任链来校验。采用SSL Pinning由于客户端绑定了服务端公钥因此我们无法通过在移动设备上信用根证书实现抓包。不过这种方式的缺点是需要小心服务端CA证书过期后续证书注意不要修改公钥。
好了以上就是咱们整个《Java 业务开发常见错误100例》这门课的30讲正文的思考题答案或者解题思路了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,88 @@
<audio id="audio" title="开篇词 | 业务代码真的会有这么多坑?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/07/85b838c641ea24155eade11547886907.mp3"></audio>
你好,我是朱晔,贝壳金服的资深架构师。
我先和你说说我这15年的工作经历吧以加深彼此的了解。前7年我专注于.NET领域负责业务项目的同时也做了很多社区工作。在CSDN做版主期间我因为回答了大量有关.NET的问题并把很多问题的答案总结成了博客获得了3次微软MVP的称号。
后来我转到了Java领域也从程序员变为了架构师更关注开源项目和互联网架构设计。在空中网我整体负责了百万人在线的大型MMO网游《激战》技术平台的架构设计期间和团队开发了许多性能和稳定性都不错的Java框架在饿了么我负责过日千万订单量的物流平台的开发管理和架构工作遇到了许多只有高并发下才会出现的问题积累了大量的架构经验现在我在贝壳金服的基础架构团队负责基础组件、中间件、基础服务开发规划制定一些流程和规范带领团队自研Java后端开发框架、微服务治理平台等在落地Spring Cloud结合Kubernetes容器云平台技术体系的过程中摸索出了很多适合公司项目的基础组件和最佳实践。
这15年来我一直没有脱离编码工作接触过大大小小的项目不下400个自己亲身经历的、见别人踩过的坑不计其数。我感触很深的一点是业务代码中真的有太多的坑有些是看似非常简单的知识点反而容易屡次踩坑比如Spring声明式事务不生效的问题而有些坑因为“潜伏期”长引发的线上事故造成了大量的人力和资金损失。因此我系统梳理了这些案例和坑点最终筛选出100个案例涉及130多个坑点组成了这个课程。
## 意识不到业务代码的坑,很危险
我想看到100、130这两个数字你不禁要问了“我写了好几年的业务代码了遇到问题时上网搜一下就有答案遇到最多的问题就是服务器不稳定重启一下基本就可以解决哪里会有这么多坑呢”带着这个问题你继续听我往下说吧。
据我观察,很多开发同学没意识到这些坑,有以下三种可能:
- 意识不到坑的存在比如所谓的服务器不稳定很可能是代码问题导致的很多时候遇到OOM、死锁、超时问题在运维层面通过改配置、重启、扩容等手段解决了没有反推到开发层面去寻找根本原因。
- 有些问题只会在特定情况下暴露。比如,缓存击穿、在多线程环境使用非线程安全的类,只有在多线程或高并发的情况才会暴露问题。
- 有些性能问题不会导致明显的Bug只会让程序运行缓慢、内存使用增加但会在量变到质变的瞬间爆发。
而正是因为没有意识到这些坑和问题,采用了错误的处理方式,最后问题一旦爆发,处理起来就非常棘手,这是非常可怕的。下面这些场景有没有感觉似曾相识呢?
比如,我曾听说过有一个订单量很大的项目,每天总有上千份订单的状态或流程有问题,需要花费大量的时间来核对数据,修复订单状态。开发同学因为每天牵扯太多精力在排查问题上,根本没时间开发新需求。技术负责人为此头痛不已,无奈之下招了专门的技术支持人员。最后痛定思痛,才决定开启明细日志彻查这个问题,结果发现是自调用方法导致事务没生效的坑。
再比如有个朋友告诉我他们的金融项目计算利息的代码中使用了float类型而不是BigDecimal类来保存和计算金额导致给用户结算的每一笔利息都多了几分钱。好在日终对账及时发现了问题。试想一下结算的有上千个用户每个用户有上千笔小订单如果等月终对账的时候再发现可能已经损失了几百万。
再比如我们使用RabbitMQ做异步处理业务处理失败的消息会循环不断地进入MQ。问题爆发之前可能只影响了消息处理的时效性。但等MQ彻底瘫痪时面对MQ中堆积的、混杂了死信和正常消息的几百万条数据你除了清空又能怎么办。但清空MQ就意味着要花费几小时甚至几十小时的时间来补正常的业务数据对业务影响时间很长。
像这样由一个小坑引发的重大事故不仅仅会给公司造成损失还会因为自责影响工作状态降低编码的自信心。我就曾遇到过一位比较负责的核心开发同学因为一个Bug给公司带来数万元的经济损失最后心理上承受不住提出了辞职。
其实,很多时候不是我们不想从根本上解决问题,只是不知道问题到底在了哪里。要避开这些坑、找到这些定时炸弹,第一步就是得知道它们是什么、在哪里、为什么会出现。而讲清楚这些坑点和相关的最佳实践,正是本课程的主要内容。
## 这个课程是什么?
如果用几个关键词概括这个课程的话那我会选择“Java”“业务开发”“避坑100例”这3个。接下来我就和你详细说说这个课程是什么以及有什么特点。
**第一个关键词是“Java”**指的是课程内所有Demo都是基于Java语言的。
如果你熟悉Java那可以100%体会到这些坑点也可以直接用这些Demo去检查你的业务代码是否也有类似的错误实现。
如果你不熟悉Java问题也不大现在大部分高级语言的特性和结构都差不多许多都是共性问题。此外“设计篇”“安全篇”的内容基本是脱离具体语言层面的、高层次的问题。因此即使不使用Java你也可以有不少收获这也是本课程的第一个特点。
讲到这里我要说明的是这个课程是围绕坑点而不是Java语言体系展开的因此不是系统学习Java的教材。
**第二个关键词是“业务开发”,也就是说课程内容限定在业务项目的开发,侧重业务项目开发时可能遇到的坑。**
我们先看“业务”这个词。做业务开发时间长的同学尤其知道,业务项目有两大特点:
- 工期紧、逻辑复杂,开发人员会更多地考虑主流程逻辑的正确实现,忽略非主流程逻辑,或保障、补偿、一致性逻辑的实现;
- 往往缺乏详细的设计、监控和容量规划的闭环,结果就是随着业务发展出现各种各样的事故。
根据这些性质我总结出了近30个方面的内容力求覆盖业务项目开发的关键问题。案例的全面性是本课程的第二大特点。
这些案例可以看作是Java业务代码的避坑大全帮助你写出更好的代码也能帮你进一步补全知识网增加面试的信心。你甚至可以把二级目录当作代码审核的Checklist帮助业务项目一起成长和避坑。
我们再看“开发”这个词。为了更聚焦,也更有针对性,我把专栏内容限定在业务开发,不会过多地讨论架构、测试、部署运维等阶段的问题。而“设计篇”,重在讲述架构设计上可能会遇到的坑,不会全面、完整地介绍高可用、高并发、可伸缩性等架构因素。
**第三个关键词是“避坑100例”。坑就是容易犯的错避坑就是踩坑后分析根因避免重复踩同样的坑。**
整个课程30篇文章涉及100个案例、约130个小坑其中40%来自于我经历过或者是见过的200多个线上生产事故剩下的60%来自于我开发业务项目,以及日常审核别人的代码发现的问题。贴近实际,而不是讲述过时的或日常开发根本用不到的技术或框架,就是本课程的第三大特点了。
大部分案例我会配合一个可执行的Demo来演示Demo中不仅有错误实现踩坑还有修正后的正确实现避坑。完整且连续、授人以渔是本课程的第四大特点。
- 完整且连续,知其所以然。我会按照“知识介绍-&gt;还原业务场景-&gt;错误实现-&gt;正确实现-&gt;原理分析-&gt;小总结 ”来讲解每个案例,针对每个坑点我至少会给出一个解决方案,并会挑选核心的点和你剖析源码。这样一来,你不仅能避坑,更能知道产生坑的根本原因,提升自己的技术能力。
- 授人以渔。在遇到问题的时候,我们一定是先通过经验和工具来定位分析问题,然后才能定位到坑,并不是一开始就知道为什么的。在这个课程中,我会尽可能地把分析问题的过程完整地呈现给你,而不是直接告诉你为什么,这样你以后遇到问题时也能有解决问题的思路。
这也是为什么网络上虽然有很多关于Java代码踩坑的资料但很多同学却和我反馈说看过之后印象不深刻也因为没吃透导致在一个知识点上重复踩坑。鉴于此我还会与你分析我根据多年经验和思考梳理出的一些最佳实践。
看到这里,是不是迫不及待地想要看看这个专栏的内容都会涉及哪些坑点了呢?那就看看下面这张思维导图吧:
<img src="https://static001.geekbang.org/resource/image/0e/20/0ee7e3490bae45d6f0ce06a050695020.jpg" alt="">
鉴于这个专栏的内容和特点,我再和你说说最佳的学习方式是什么。
## 学习课程的最佳方法
我们都知道,编程是一门实践科学,只看不练、不思考,效果通常不会太好。因此,我建议你打开每篇文章后,能够按照下面的方式深入学习:
1. 对于每一个坑点,实际运行调试一下源码,使用文中提到的工具和方法重现问题,眼见为实。
1. 对于每一个坑点,再思考下除了文内的解决方案和思路外,是否还有其他修正方式。
1. 对于坑点根因中涉及的JDK或框架源码分析你可以找到相关类再系统阅读一下源码。
1. 实践课后思考题。这些思考题,有的是对文章内容的补充,有的是额外容易踩的坑。
理解了课程涉及的所有案例后,你应该就对业务代码大部分容易犯错的点了如指掌了,不仅仅自己可以写出更高质量的业务代码,还可以在审核别人代码时发现可能存在的问题,帮助整个团队成长。
当然了,你从这个课程收获的将不仅是解决案例中那些问题的方法,还可以提升自己分析定位问题、阅读源码的能力。当你再遇到其他诡异的坑时,也能有清晰的解决思路,也可以成长为一名救火专家,帮助大家一起定位、分析问题。
好了,以上就是我今天想要和你分享的内容了。请赶快跟随我们的课程开启避坑之旅吧,也欢迎你留言说说自己的情况,你都踩过哪些坑、对写业务代码又有哪些困惑?我们下一讲见!

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="结束语 | 写代码时,如何才能尽量避免踩坑?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/03/d588e7fe05c091169ef75ad7c850fc03.mp3"></audio>
你好,我是朱晔。
这个课程要告一段落了,在这里我要特别感谢你一直以来的认可与陪伴。于我而言,虽然这半年多以来我几乎所有的业余时间都用了在这个课程的创作,以及回答你的问题上,很累很辛苦,但是看到你的认真学习和对课程内容的好评,看到你不仅收获了知识还燃起了钻研源码的热情,我也非常高兴,深觉一切的辛苦付出都是甜蜜的。
相信一路走来你不仅理解了业务代码开发中常见的130多个坑点的解决方式也知道了其根本原因以及如何使用一些常用工具来分析问题。这样在以后遇到各种坑的时候你就更加能有方法、有信心来解决问题。
## 如何尽量避免踩坑?
不过,学习、分析这些坑点并不是我们的最终目的,在写业务代码时如何尽量避免踩坑才是。所以,接下来,我要重点和你聊聊避免踩坑的一些方法。
所谓坑往往就是我们意识不到的陷进。虽然这个课程覆盖了130多个业务开发时可能会出错的点但我相信在整个Java开发领域还有成千上万个可能会踩的坑。同时随着Java语言以及各种新框架、新技术的产生我们还会不断遇到各种坑很难有一种方式确保永远不会遇到新问题。
而我们能做的就是尽可能少踩坑或者减少踩坑给我们带来的影响。鉴于此我还有10条建议要分享给你。
**第一,遇到自己不熟悉的新类,在不了解之前不要随意使用。**
比如,我在[并发工具这](https://time.geekbang.org/column/article/209494)一讲中提到的CopyOnWriteArrayList。如果你仅仅认为CopyOnWriteArrayList是ArrayList的线程安全版本在不知晓原理之前把它用于大量写操作的场景那么很可能会遇到性能问题。
JDK或各种框架随着时间的推移会不断推出各种特殊类用于极致化各种细化场景下的程序性能。在使用这些类之前我们需要认清楚这些类的由来以及要解决的问题在确认自己的场景符合的情况下再去使用。
而且,越普适的工具类通常用起来越简单,越高级的类用起来越复杂,也更容易踩坑。比如,[代码加锁](https://time.geekbang.org/column/article/209520)这一讲中提到的锁工具类StampedLock就比ReentrantLock或者synchronized的用法复杂得多很容易踩坑。
**第二,尽量使用更高层次的框架。**
通常情况下,偏底层的框架趋向于提供更多细节的配置,尽可能让使用者根据自己的需求来进行不同的配置,而较少考虑最佳实践的问题;而高层次的框架,则会更多地考虑怎么方便开发者开箱即用。
比如,在[HTTP请求](https://time.geekbang.org/column/article/213273)这一讲中我们谈到Apache HttpClient的并发数限制问题。如果你使用Spring Cloud Feign搭配HttpClient就不会遇到单域名默认2个并发连接的问题。因为Spring Cloud Feign已经把这个参数设置为了50足够应对一般场景了。
**第三,关注各种框架和组件的安全补丁和版本更新。**
比如我们使用的Tomcat服务器、序列化框架等就是黑客关注的安全突破口。我们需要及时关注这些组件和框架的稳定大版本和补丁并及时更新升级以避免组件和框架本身的性能问题或安全问题带来的大坑。
**第四,尽量少自己造轮子,使用流行的框架。**
流行框架最大的好处是成熟,在经过大量用户的使用打磨后,你能想到、能遇到的所有问题几乎别人都遇到了,框架中也有了解决方案。很多时候我们会以“轻量级”为由来造轮子,但其实很多复杂的框架,一开始也是轻量的。只不过是,这些框架经过各种迭代解决了各种问题,做了很多可扩展性预留之后,才变得越来越复杂,而并不一定是框架本身的设计臃肿。
如果我们自己去开发框架的话很可能会踩一些别人已经踩过的坑。比如直接使用JDK NIO来开发网络程序或网络框架的话我们可能会遇到epoll的selector空轮询Bug最终导致 CPU 100%。而Netty规避了这些问题因此使用Netty开发NIO网络程序不但简单而且可以少踩很多坑。
**第五,开发的时候遇到错误,除了搜索解决方案外,更重要的是理解原理。**
比如,在[OOM](https://time.geekbang.org/column/article/224784)这一讲我提到的配置超大server.max-http-header-size参数导致的OOM问题可能就是来自网络的解决方案。网络上别人给出的解决方案可能只是适合“自己”不一定适合所有人。并且各种框架迭代很频繁今天有效的解决方案明天可能就无效了今天有效的参数配置新版本可能就不再建议使用甚至失效了。
因此,只有知其所以然,才能从根本上避免踩坑。
**第六,网络上的资料有很多,但不一定可靠,最可靠的还是官方文档。**
比如搜索Java 8的一些介绍你可以看到有些资料提到了在Java 8中Files.lines方法进行文件读取更高效但是Demo代码并没使用try-with-resources来释放资源。在[文件IO](https://time.geekbang.org/column/article/223051)这一讲中,我和你讲解了这么做会导致文件句柄无法释放。
其实网上的各种资料本来就是大家自己学习分享的经验和心得不一定都是对的。另外这些资料给出的都是Demo演示的是某个类在某方面的功能不一定会面面俱到地考虑到资源释放、并发等问题。
因此对于系统学习某个组件或框架我最推荐的还是JDK或者三方库的官方文档。这些文档基本不会出现错误的示例一般也会提到使用的最佳实践以及最需要注意的点。
**第七,做好单元测试和性能测试。**
如果你开发的是一个偏底层的服务或框架,有非常多的受众和分支流程,那么单元测试(或者是自动化测试)就是必须的。
人工测试一般针对主流程和改动点,只有单元测试才可以确保任何一次改动不会影响现有服务的每一个细节点。此外,许多坑都涉及线程安全、资源使用,这些问题只有在高并发的情况下才会产生。没有经过性能测试的代码,只能认为是完成了功能,还不能确保健壮性、可扩展性和可靠性。
**第八,做好设计评审和代码审查工作。**
人都会犯错,而且任何一个人的知识都有盲区。因此,项目的设计如果能提前有专家组进行评审,每一段代码都能有至少三个人进行代码审核,就可以极大地减少犯错的可能性。
比如对于熟悉IO的开发者来说他肯定知道[文件的读写](https://time.geekbang.org/column/article/223051)需要基于缓冲区。如果他看到另一个同事提交的代码,是以单字节的方式来读写文件,就可以提前发现代码的性能问题。
又比如,一些比较老的资料仍然提倡使用[MD5摘要](https://time.geekbang.org/column/article/239150)来保存密码。但是现在MD5已经不安全了。如果项目设计已经由公司内安全经验丰富的架构师和安全专家评审过就可以提前避免安全疏漏。
**第九,借助工具帮我们避坑。**
其实我们犯很多低级错误时并不是自己不知道而是因为疏忽。就好像是即使我们知道可能存在这100个坑但如果让我们一条一条地确认所有代码是否有这些坑我们也很难办到。但是如果我们可以把规则明确的坑使用工具来检测就可以避免大量的低级错误。
比如使用YYYY进行[日期格式化](https://time.geekbang.org/column/article/224240)的坑、使用==进行[判等](https://time.geekbang.org/column/article/213604)的坑、[List.subList](https://time.geekbang.org/column/article/216778)原List和子List相互影响的坑等都可以通过[阿里P3C代码规约扫描插件](https://github.com/alibaba/p3c)发现。我也建议你为IDE安装这个插件。
此外我还建议在CI流程中集成[Sonarqube](https://www.sonarqube.org/)代码静态扫描平台,对需要构建发布的代码进行全面的代码质量扫描。
**第十,做好完善的监控报警。**
诸如[内存泄露](https://time.geekbang.org/column/article/224784)、[文件句柄不释放](https://time.geekbang.org/column/article/223051)、[线程泄露](https://time.geekbang.org/column/article/211388)等消耗型问题往往都是量变积累成为质变最后才会造成进程崩溃。如果一开始我们就可以对应用程序的内存使用、文件句柄使用、IO使用量、网络带宽、TCP连接、线程数等各种指标进行监控并且基于合理阈值设置报警那么可能就能在事故的婴儿阶段及时发现问题、解决问题。
此外,在遇到报警的时候,我们不能凭经验想当然地认为这些问题都是已知的,对报警置之不理。我们要牢记,所有报警都需要处理和记录。
以上就是我要分享给你的10条建议了。用好这10条建议可以帮助我们很大程度提前发现Java开发中的一些坑、避免一些压力引起的生产事故或是减少踩坑的影响。
最后,正所谓师傅领进门,修行靠个人,希望你在接下来学习技术和写代码的过程中,能够养成多研究原理、多思考总结问题的习惯,点点滴滴补全自己的知识网络。对代码精益求精,写出健壮的代码,线上问题少了,不但自己的心情好了,也能得到更多认可,并有更多时间来学习提升。这样,我们的个人成长就会比较快,形成正向循环。
另外,如果你有时间,我想请你帮我填个[课程问卷](https://jinshuju.net/f/pkRg24),和我反馈你对这个课程的想法和建议。今天虽然是结课,但我还会继续关注你的留言,也希望你能继续学习这个课程的内容,并会通过留言区和你互动。
你还可以继续把这个课程分享给身边的朋友和同事我们继续交流、讨论在写Java业务代码时可能会犯的错儿。

View File

@@ -0,0 +1,10 @@
你好,我是朱晔。
《Java业务开发常见错误100例》这门课程已经全部结束了。我给你准备了一套结课测试题。它既可以是对你学习效果的一个检验也可以被看作对于课程内容的一个系统性回顾。
这套测试题共有 20 道题目,包括 8道单选题和 12道多选题满分 100 分,系统自动评分。
还等什么,点击下面按钮开始测试吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=162&amp;exam_id=368)

View File

@@ -0,0 +1,14 @@
你好!
截至今天,这门课有奖收集结课问卷阶段就结束了,十分感谢你的参与。现在我们来公布一下获奖用户名单。
在这里,我首先要感谢各位同学给我们的反馈。在这些反馈中,我们看到了很多非常有价值的信息,也收获了很多的支持与肯定。这些声音,都会促使我们继续精益求精。
在此,我们本着“对专栏课程的改进最有帮助”的原则,精选出了反馈最为具体、丰富,最有实际价值的 5 位用户送出“Git/Redis快捷口令超大鼠标垫”或者“价值 99 元的极客时间课程阅码”。中奖名单如下:
<img src="https://static001.geekbang.org/resource/image/23/55/237abf12fe1dc622895f29fbc53bee55.jpg" alt="">
恭喜这 5 位同学,也再次感谢所有参与调研的同学。希望各位同学还能继续学习这个课程对你最有价值的内容,并把你的学习收获今后还能多多支持,给予宝贵意见。
当然我还会继续关注你的留言也希望你能继续学习这个课程的内容并会通过留言区和你互动。所以你还可以继续把这个课程分享给身边的朋友和同事我们继续交流、讨论在写Java业务代码时可能会犯的错儿。

View File

@@ -0,0 +1,649 @@
<audio id="audio" title="21 | 代码重复:搞定代码重复的三个绝招" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/ad/9097195c3ed02c43a901dd9db67260ad.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊搞定代码重复的三个绝招。
业务同学抱怨业务开发没有技术含量用不到设计模式、Java高级特性、OOP平时写代码都在堆CRUD个人成长无从谈起。每次面试官问到“请说说平时常用的设计模式”都只能答单例模式因为其他设计模式的确是听过但没用过对于反射、注解之类的高级特性也只是知道它们在写框架的时候非常常用但自己又不写框架代码没有用武之地。
其实我认为不是这样的。设计模式、OOP是前辈们在大型项目中积累下来的经验通过这些方法论来改善大型项目的可维护性。反射、注解、泛型等高级特性在框架中大量使用的原因是框架往往需要以同一套算法来应对不同的数据结构而这些特性可以帮助减少重复代码提升项目可维护性。
在我看来,可维护性是大型项目成熟度的一个重要指标,而提升可维护性非常重要的一个手段就是减少代码重复。那为什么这样说呢?
- 如果多处重复代码实现完全相同的功能很容易修改一处忘记修改另一处造成Bug
- 有一些代码并不是完全重复,而是相似度很高,修改这些类似的代码容易改(复制粘贴)错,把原本有区别的地方改为了一样。
今天我就从业务代码中最常见的三个需求展开和你聊聊如何使用Java中的一些高级特性、设计模式以及一些工具消除重复代码才能既优雅又高端。通过今天的学习也希望改变你对业务代码没有技术含量的看法。
## 利用工厂模式+模板方法模式消除if…else和重复代码
假设要开发一个购物车下单的功能,针对不同用户进行不同处理:
- 普通用户需要收取运费运费是商品价格的10%,无商品折扣;
- VIP用户同样需要收取商品价格10%的快递费,但购买两件以上相同商品时,第三件开始享受一定折扣;
- 内部用户可以免运费,无商品折扣。
我们的目标是实现三种类型的购物车业务逻辑把入参Map对象Key是商品IDValue是商品数量转换为出参购物车类型Cart。
先实现针对普通用户的购物车处理逻辑:
```
//购物车
@Data
public class Cart {
//商品清单
private List&lt;Item&gt; items = new ArrayList&lt;&gt;();
//总优惠
private BigDecimal totalDiscount;
//商品总价
private BigDecimal totalItemPrice;
//总运费
private BigDecimal totalDeliveryPrice;
//应付总价
private BigDecimal payPrice;
}
//购物车中的商品
@Data
public class Item {
//商品ID
private long id;
//商品数量
private int quantity;
//商品单价
private BigDecimal price;
//商品优惠
private BigDecimal couponPrice;
//商品运费
private BigDecimal deliveryPrice;
}
//普通用户购物车处理
public class NormalUserCart {
public Cart process(long userId, Map&lt;Long, Integer&gt; items) {
Cart cart = new Cart();
//把Map的购物车转换为Item列表
List&lt;Item&gt; itemList = new ArrayList&lt;&gt;();
items.entrySet().stream().forEach(entry -&gt; {
Item item = new Item();
item.setId(entry.getKey());
item.setPrice(Db.getItemPrice(entry.getKey()));
item.setQuantity(entry.getValue());
itemList.add(item);
});
cart.setItems(itemList);
//处理运费和商品优惠
itemList.stream().forEach(item -&gt; {
//运费为商品总价的10%
item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal(&quot;0.1&quot;)));
//无优惠
item.setCouponPrice(BigDecimal.ZERO);
});
//计算商品总价
cart.setTotalItemPrice(cart.getItems().stream().map(item -&gt; item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算运费总价
cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算总优惠
cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//应付总价=商品总价+运费总价-总优惠
cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
return cart;
}
}
```
然后实现针对VIP用户的购物车逻辑。与普通用户购物车逻辑的不同在于VIP用户能享受同类商品多买的折扣。所以这部分代码只需要额外处理多买折扣部分
```
public class VipUserCart {
public Cart process(long userId, Map&lt;Long, Integer&gt; items) {
...
itemList.stream().forEach(item -&gt; {
//运费为商品总价的10%
item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal(&quot;0.1&quot;)));
//购买两件以上相同商品,第三件开始享受一定折扣
if (item.getQuantity() &gt; 2) {
item.setCouponPrice(item.getPrice()
.multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal(&quot;100&quot;)))
.multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
} else {
item.setCouponPrice(BigDecimal.ZERO);
}
});
...
return cart;
}
}
```
最后是免运费、无折扣的内部用户,同样只是处理商品折扣和运费时的逻辑差异:
```
public class InternalUserCart {
public Cart process(long userId, Map&lt;Long, Integer&gt; items) {
...
itemList.stream().forEach(item -&gt; {
//免运费
item.setDeliveryPrice(BigDecimal.ZERO);
//无优惠
item.setCouponPrice(BigDecimal.ZERO);
});
...
return cart;
}
}
```
对比一下代码量可以发现三种购物车70%的代码是重复的。原因很简单,虽然不同类型用户计算运费和优惠的方式不同,但整个购物车的初始化、统计总价、总运费、总优惠和支付价格的逻辑都是一样的。
正如我们开始时提到的代码重复本身不可怕可怕的是漏改或改错。比如写VIP用户购物车的同学发现商品总价计算有Bug不应该是把所有Item的price加在一起而是应该把所有Item的price*quantity加在一起。这时他可能会只修改VIP用户购物车的代码而忽略了普通用户、内部用户的购物车中重复的逻辑实现也有相同的Bug。
有了三个购物车后我们就需要根据不同的用户类型使用不同的购物车了。如下代码所示使用三个if实现不同类型用户调用不同购物车的process方法
```
@GetMapping(&quot;wrong&quot;)
public Cart wrong(@RequestParam(&quot;userId&quot;) int userId) {
//根据用户ID获得用户类型
String userCategory = Db.getUserCategory(userId);
//普通用户处理逻辑
if (userCategory.equals(&quot;Normal&quot;)) {
NormalUserCart normalUserCart = new NormalUserCart();
return normalUserCart.process(userId, items);
}
//VIP用户处理逻辑
if (userCategory.equals(&quot;Vip&quot;)) {
VipUserCart vipUserCart = new VipUserCart();
return vipUserCart.process(userId, items);
}
//内部用户处理逻辑
if (userCategory.equals(&quot;Internal&quot;)) {
InternalUserCart internalUserCart = new InternalUserCart();
return internalUserCart.process(userId, items);
}
return null;
}
```
电商的营销玩法是多样的以后势必还会有更多用户类型需要更多的购物车。我们就只能不断增加更多的购物车类一遍一遍地写重复的购物车逻辑、写更多的if逻辑吗
当然不是,相同的代码应该只在一处出现!
如果我们熟记抽象类和抽象方法的定义的话,这时或许就会想到,是否可以把重复的逻辑定义在抽象类中,三个购物车只要分别实现不同的那份逻辑呢?
其实,这个模式就是**模板方法模式**。我们在父类中实现了购物车处理的流程模板,然后把需要特殊处理的地方留空白也就是留抽象方法定义,让子类去实现其中的逻辑。由于父类的逻辑不完整无法单独工作,因此需要定义为抽象类。
如下代码所示AbstractCart抽象类实现了购物车通用的逻辑额外定义了两个抽象方法让子类去实现。其中processCouponPrice方法用于计算商品折扣processDeliveryPrice方法用于计算运费。
```
public abstract class AbstractCart {
//处理购物车的大量重复逻辑在父类实现
public Cart process(long userId, Map&lt;Long, Integer&gt; items) {
Cart cart = new Cart();
List&lt;Item&gt; itemList = new ArrayList&lt;&gt;();
items.entrySet().stream().forEach(entry -&gt; {
Item item = new Item();
item.setId(entry.getKey());
item.setPrice(Db.getItemPrice(entry.getKey()));
item.setQuantity(entry.getValue());
itemList.add(item);
});
cart.setItems(itemList);
//让子类处理每一个商品的优惠
itemList.stream().forEach(item -&gt; {
processCouponPrice(userId, item);
processDeliveryPrice(userId, item);
});
//计算商品总价
cart.setTotalItemPrice(cart.getItems().stream().map(item -&gt; item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算总运费
cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算总折扣
cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算应付价格
cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
return cart;
}
//处理商品优惠的逻辑留给子类实现
protected abstract void processCouponPrice(long userId, Item item);
//处理配送费的逻辑留给子类实现
protected abstract void processDeliveryPrice(long userId, Item item);
}
```
有了这个抽象类三个子类的实现就非常简单了。普通用户的购物车NormalUserCart实现的是0优惠和10%运费的逻辑:
```
@Service(value = &quot;NormalUserCart&quot;)
public class NormalUserCart extends AbstractCart {
@Override
protected void processCouponPrice(long userId, Item item) {
item.setCouponPrice(BigDecimal.ZERO);
}
@Override
protected void processDeliveryPrice(long userId, Item item) {
item.setDeliveryPrice(item.getPrice()
.multiply(BigDecimal.valueOf(item.getQuantity()))
.multiply(new BigDecimal(&quot;0.1&quot;)));
}
}
```
VIP用户的购物车VipUserCart直接继承了NormalUserCart只需要修改多买优惠策略
```
@Service(value = &quot;VipUserCart&quot;)
public class VipUserCart extends NormalUserCart {
@Override
protected void processCouponPrice(long userId, Item item) {
if (item.getQuantity() &gt; 2) {
item.setCouponPrice(item.getPrice()
.multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal(&quot;100&quot;)))
.multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
} else {
item.setCouponPrice(BigDecimal.ZERO);
}
}
}
```
内部用户购物车InternalUserCart是最简单的直接设置0运费和0折扣即可
```
@Service(value = &quot;InternalUserCart&quot;)
public class InternalUserCart extends AbstractCart {
@Override
protected void processCouponPrice(long userId, Item item) {
item.setCouponPrice(BigDecimal.ZERO);
}
@Override
protected void processDeliveryPrice(long userId, Item item) {
item.setDeliveryPrice(BigDecimal.ZERO);
}
}
```
抽象类和三个子类的实现关系图,如下所示:
<img src="https://static001.geekbang.org/resource/image/55/03/55ec188c32805608e0f2341655c87f03.png" alt="">
是不是比三个独立的购物车程序简单了很多呢接下来我们再看看如何能避免三个if逻辑。
或许你已经注意到了,定义三个购物车子类时,我们在@Service注解中对Bean进行了命名。既然三个购物车都叫XXXUserCart那我们就可以把用户类型字符串拼接UserCart构成购物车Bean的名称然后利用Spring的IoC容器通过Bean的名称直接获取到AbstractCart调用其process方法即可实现通用。
其实,这就是**工厂模式**只不过是借助Spring容器实现罢了
```
@GetMapping(&quot;right&quot;)
public Cart right(@RequestParam(&quot;userId&quot;) int userId) {
String userCategory = Db.getUserCategory(userId);
AbstractCart cart = (AbstractCart) applicationContext.getBean(userCategory + &quot;UserCart&quot;);
return cart.process(userId, items);
}
```
试想, 之后如果有了新的用户类型、新的用户逻辑是不是完全不用对代码做任何修改只要新增一个XXXUserCart类继承AbstractCart实现特殊的优惠和运费处理逻辑就可以了
**这样一来,我们就利用工厂模式+模板方法模式,不仅消除了重复代码,还避免了修改既有代码的风险**。这就是设计模式中的开闭原则:对修改关闭,对扩展开放。
## 利用注解+反射消除重复代码
是不是有点兴奋了业务代码居然也能OOP了。我们再看一个三方接口的调用案例同样也是一个普通的业务逻辑。
假设银行提供了一些API接口对参数的序列化有点特殊不使用JSON而是需要我们把参数依次拼在一起构成一个大字符串。
- 按照银行提供的API文档的顺序把所有参数构成定长的数据然后拼接在一起作为整个字符串。
<li>因为每一种参数都有固定长度,未达到长度时需要做填充处理:
<ul>
- 字符串类型的参数不满长度部分需要以下划线右填充,也就是字符串内容靠左;
- 数字类型的参数不满长度部分以0左填充也就是实际数字靠右
- 货币类型的表示需要把金额向下舍入2位到分以分为单位作为数字类型同样进行左填充。
比如,创建用户方法和支付方法的定义是这样的:
<img src="https://static001.geekbang.org/resource/image/54/a6/5429e0313c1254c56abf6bc6ff4fc8a6.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/88/07/88ceb410987e16f00b5ab5324c0f4c07.jpg" alt="">
代码很容易实现,直接根据接口定义实现填充操作、加签名、请求调用操作即可:
```
public class BankService {
//创建用户方法
public static String createUser(String name, String identity, String mobile, int age) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
//字符串靠左多余的地方填充_
stringBuilder.append(String.format(&quot;%-10s&quot;, name).replace(' ', '_'));
//字符串靠左多余的地方填充_
stringBuilder.append(String.format(&quot;%-18s&quot;, identity).replace(' ', '_'));
//数字靠右多余的地方用0填充
stringBuilder.append(String.format(&quot;%05d&quot;, age));
//字符串靠左多余的地方用_填充
stringBuilder.append(String.format(&quot;%-11s&quot;, mobile).replace(' ', '_'));
//最后加上MD5作为签名
stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
return Request.Post(&quot;http://localhost:45678/reflection/bank/createUser&quot;)
.bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
.execute().returnContent().asString();
}
//支付方法
public static String pay(long userId, BigDecimal amount) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
//数字靠右多余的地方用0填充
stringBuilder.append(String.format(&quot;%020d&quot;, userId));
//金额向下舍入2位到分以分为单位作为数字靠右多余的地方用0填充
stringBuilder.append(String.format(&quot;%010d&quot;, amount.setScale(2, RoundingMode.DOWN).multiply(new BigDecimal(&quot;100&quot;)).longValue()));
//最后加上MD5作为签名
stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
return Request.Post(&quot;http://localhost:45678/reflection/bank/pay&quot;)
.bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
.execute().returnContent().asString();
}
}
```
可以看到,这段代码的重复粒度更细:
- 三种标准数据类型的处理逻辑有重复稍有不慎就会出现Bug
- 处理流程中字符串拼接、加签和发请求的逻辑,在所有方法重复;
- 实际方法的入参的参数类型和顺序,不一定和接口要求一致,容易出错;
- 代码层面针对每一个参数硬编码,无法清晰地进行核对,如果参数达到几十个、上百个,出错的概率极大。
那应该如何改造这段代码呢?没错,就是要用注解和反射!
使用注解和反射这两个武器,就可以针对银行请求的所有逻辑均使用一套代码实现,不会出现任何重复。
要实现接口逻辑和逻辑实现的剥离首先需要以POJO类只有属性没有任何业务逻辑的数据类的方式定义所有的接口参数。比如下面这个创建用户API的参数
```
@Data
public class CreateUserAPI {
private String name;
private String identity;
private String mobile;
private int age;
}
```
有了接口参数定义我们就能通过自定义注解为接口和所有参数增加一些元数据。如下所示我们定义一个接口API的注解BankAPI包含接口URL地址和接口说明
```
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Inherited
public @interface BankAPI {
String desc() default &quot;&quot;;
String url() default &quot;&quot;;
}
```
然后,我们再定义一个自定义注解@BankAPIField,用于描述接口的每一个字段规范,包含参数的次序、类型和长度三个属性:
```
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
@Inherited
public @interface BankAPIField {
int order() default -1;
int length() default -1;
String type() default &quot;&quot;;
}
```
接下来,注解就可以发挥威力了。
如下所示我们定义了CreateUserAPI类描述创建用户接口的信息通过为接口增加@BankAPI注解来补充接口的URL和描述等元数据通过为每一个字段增加@BankAPIField注解,来补充参数的顺序、类型和长度等元数据:
```
@BankAPI(url = &quot;/bank/createUser&quot;, desc = &quot;创建用户接口&quot;)
@Data
public class CreateUserAPI extends AbstractAPI {
@BankAPIField(order = 1, type = &quot;S&quot;, length = 10)
private String name;
@BankAPIField(order = 2, type = &quot;S&quot;, length = 18)
private String identity;
@BankAPIField(order = 4, type = &quot;S&quot;, length = 11) //注意这里的order需要按照API表格中的顺序
private String mobile;
@BankAPIField(order = 3, type = &quot;N&quot;, length = 5)
private int age;
}
```
另一个PayAPI类也是类似的实现
```
@BankAPI(url = &quot;/bank/pay&quot;, desc = &quot;支付接口&quot;)
@Data
public class PayAPI extends AbstractAPI {
@BankAPIField(order = 1, type = &quot;N&quot;, length = 20)
private long userId;
@BankAPIField(order = 2, type = &quot;M&quot;, length = 10)
private BigDecimal amount;
}
```
这2个类继承的AbstractAPI类是一个空实现因为这个案例中的接口并没有公共数据可以抽象放到基类。
通过这2个类我们可以在几秒钟内完成和API清单表格的核对。理论上如果我们的核心翻译过程也就是把注解和接口API序列化为请求需要的字符串的过程没问题只要注解和表格一致API请求的翻译就不会有任何问题。
以上我们通过注解实现了对API参数的描述。接下来我们再看看反射如何配合注解实现动态的接口参数组装
- 第3行代码中我们从类上获得了BankAPI注解然后拿到其URL属性后续进行远程调用。
- 第6~9行代码使用stream快速实现了获取类中所有带BankAPIField注解的字段并把字段按order属性排序然后设置私有字段反射可访问。
- 第12~38行代码实现了反射获取注解的值然后根据BankAPIField拿到的参数类型按照三种标准进行格式化将所有参数的格式化逻辑集中在了这一处。
- 第41~48行代码实现了参数加签和请求调用。
```
private static String remoteCall(AbstractAPI api) throws IOException {
//从BankAPI注解获取请求地址
BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
bankAPI.url();
StringBuilder stringBuilder = new StringBuilder();
Arrays.stream(api.getClass().getDeclaredFields()) //获得所有字段
.filter(field -&gt; field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段
.sorted(Comparator.comparingInt(a -&gt; a.getAnnotation(BankAPIField.class).order())) //根据注解中的order对字段排序
.peek(field -&gt; field.setAccessible(true)) //设置可以访问私有字段
.forEach(field -&gt; {
//获得注解
BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class);
Object value = &quot;&quot;;
try {
//反射获取字段值
value = field.get(api);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
//根据字段类型以正确的填充方式格式化字符串
switch (bankAPIField.type()) {
case &quot;S&quot;: {
stringBuilder.append(String.format(&quot;%-&quot; + bankAPIField.length() + &quot;s&quot;, value.toString()).replace(' ', '_'));
break;
}
case &quot;N&quot;: {
stringBuilder.append(String.format(&quot;%&quot; + bankAPIField.length() + &quot;s&quot;, value.toString()).replace(' ', '0'));
break;
}
case &quot;M&quot;: {
if (!(value instanceof BigDecimal))
throw new RuntimeException(String.format(&quot;{} 的 {} 必须是BigDecimal&quot;, api, field));
stringBuilder.append(String.format(&quot;%0&quot; + bankAPIField.length() + &quot;d&quot;, ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal(&quot;100&quot;)).longValue()));
break;
}
default:
break;
}
});
//签名逻辑
stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
String param = stringBuilder.toString();
long begin = System.currentTimeMillis();
//发请求
String result = Request.Post(&quot;http://localhost:45678/reflection&quot; + bankAPI.url())
.bodyString(param, ContentType.APPLICATION_JSON)
.execute().returnContent().asString();
log.info(&quot;调用银行API {} url:{} 参数:{} 耗时:{}ms&quot;, bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin);
return result;
}
```
可以看到,**所有处理参数排序、填充、加签、请求调用的核心逻辑都汇聚在了remoteCall方法中**。有了这个核心方法BankService中每一个接口的实现就非常简单了只是参数的组装然后调用remoteCall即可。
```
//创建用户方法
public static String createUser(String name, String identity, String mobile, int age) throws IOException {
CreateUserAPI createUserAPI = new CreateUserAPI();
createUserAPI.setName(name);
createUserAPI.setIdentity(identity);
createUserAPI.setAge(age);
createUserAPI.setMobile(mobile);
return remoteCall(createUserAPI);
}
//支付方法
public static String pay(long userId, BigDecimal amount) throws IOException {
PayAPI payAPI = new PayAPI();
payAPI.setUserId(userId);
payAPI.setAmount(amount);
return remoteCall(payAPI);
}
```
其实,**许多涉及类结构性的通用处理,都可以按照这个模式来减少重复代码**。反射给予了我们在不知晓类结构的时候,按照固定的逻辑处理类的成员;而注解给了我们为这些成员补充元数据的能力,使得我们利用反射实现通用逻辑的时候,可以从外部获得更多我们关心的数据。
## 利用属性拷贝工具消除重复代码
最后,我们再来看一种业务代码中经常出现的代码逻辑,实体之间的转换复制。
对于三层架构的系统考虑到层之间的解耦隔离以及每一层对数据的不同需求通常每一层都会有自己的POJO作为数据实体。比如数据访问层的实体一般叫作DataObject或DO业务逻辑层的实体一般叫作Domain表现层的实体一般叫作Data Transfer Object或DTO。
这里我们需要注意的是,如果手动写这些实体之间的赋值代码,同样容易出错。
对于复杂的业务系统实体有几十甚至几百个属性也很正常。就比如ComplicatedOrderDTO这个数据传输对象描述的是一个订单中的几十个属性。如果我们要把这个DTO转换为一个类似的DO复制其中大部分的字段然后把数据入库势必需要进行很多属性映射赋值操作。就像这样密密麻麻的代码是不是已经让你头晕了
```
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
orderDO.setAcceptDate(orderDTO.getAcceptDate());
orderDO.setAddress(orderDTO.getAddress());
orderDO.setAddressId(orderDTO.getAddressId());
orderDO.setCancelable(orderDTO.isCancelable());
orderDO.setCommentable(orderDTO.isComplainable()); //属性错误
orderDO.setComplainable(orderDTO.isCommentable()); //属性错误
orderDO.setCancelable(orderDTO.isCancelable());
orderDO.setCouponAmount(orderDTO.getCouponAmount());
orderDO.setCouponId(orderDTO.getCouponId());
orderDO.setCreateDate(orderDTO.getCreateDate());
orderDO.setDirectCancelable(orderDTO.isDirectCancelable());
orderDO.setDeliverDate(orderDTO.getDeliverDate());
orderDO.setDeliverGroup(orderDTO.getDeliverGroup());
orderDO.setDeliverGroupOrderStatus(orderDTO.getDeliverGroupOrderStatus());
orderDO.setDeliverMethod(orderDTO.getDeliverMethod());
orderDO.setDeliverPrice(orderDTO.getDeliverPrice());
orderDO.setDeliveryManId(orderDTO.getDeliveryManId());
orderDO.setDeliveryManMobile(orderDO.getDeliveryManMobile()); //对象错误
orderDO.setDeliveryManName(orderDTO.getDeliveryManName());
orderDO.setDistance(orderDTO.getDistance());
orderDO.setExpectDate(orderDTO.getExpectDate());
orderDO.setFirstDeal(orderDTO.isFirstDeal());
orderDO.setHasPaid(orderDTO.isHasPaid());
orderDO.setHeadPic(orderDTO.getHeadPic());
orderDO.setLongitude(orderDTO.getLongitude());
orderDO.setLatitude(orderDTO.getLongitude()); //属性赋值错误
orderDO.setMerchantAddress(orderDTO.getMerchantAddress());
orderDO.setMerchantHeadPic(orderDTO.getMerchantHeadPic());
orderDO.setMerchantId(orderDTO.getMerchantId());
orderDO.setMerchantAddress(orderDTO.getMerchantAddress());
orderDO.setMerchantName(orderDTO.getMerchantName());
orderDO.setMerchantPhone(orderDTO.getMerchantPhone());
orderDO.setOrderNo(orderDTO.getOrderNo());
orderDO.setOutDate(orderDTO.getOutDate());
orderDO.setPayable(orderDTO.isPayable());
orderDO.setPaymentAmount(orderDTO.getPaymentAmount());
orderDO.setPaymentDate(orderDTO.getPaymentDate());
orderDO.setPaymentMethod(orderDTO.getPaymentMethod());
orderDO.setPaymentTimeLimit(orderDTO.getPaymentTimeLimit());
orderDO.setPhone(orderDTO.getPhone());
orderDO.setRefundable(orderDTO.isRefundable());
orderDO.setRemark(orderDTO.getRemark());
orderDO.setStatus(orderDTO.getStatus());
orderDO.setTotalQuantity(orderDTO.getTotalQuantity());
orderDO.setUpdateTime(orderDTO.getUpdateTime());
orderDO.setName(orderDTO.getName());
orderDO.setUid(orderDTO.getUid());
```
**如果不是代码中有注释,你能看出其中的诸多问题吗**
- 如果原始的DTO有100个字段我们需要复制90个字段到DO中保留10个不赋值最后应该如何校验正确性呢数数吗即使数出有90行代码也不一定正确因为属性可能重复赋值。
- 有的时候字段命名相近比如complainable和commentable容易搞反第7和第8行或者对两个目标字段重复赋值相同的来源字段比如第28行
- 明明要把DTO的值赋值到DO中却在set的时候从DO自己取值比如第20行导致赋值无效。
这段代码并不是我随手写出来的而是一个真实案例。有位同学就像代码中那样把经纬度赋值反了因为落库的字段实在太多了。这个Bug很久都没发现直到真正用到数据库中的经纬度做计算时才发现一直以来都存错了。
修改方法很简单可以使用类似BeanUtils这种Mapping工具来做Bean的转换copyProperties方法还允许我们提供需要忽略的属性
```
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
BeanUtils.copyProperties(orderDTO, orderDO, &quot;id&quot;);
return orderDO;
```
## 重点回顾
正所谓“常在河边走哪有不湿鞋”,重复代码多了总有一天会出错。今天,我从几个最常见的维度,和你分享了几个实际业务场景中可能出现的重复问题,以及消除重复的方式。
第一种代码重复是有多个并行的类实现相似的代码逻辑。我们可以考虑提取相同逻辑在父类中实现差异逻辑通过抽象方法留给子类实现。使用类似的模板方法把相同的流程和逻辑固定成模板保留差异的同时尽可能避免代码重复。同时可以使用Spring的IoC特性注入相应的子类来避免实例化子类时的大量if…else代码。
第二种代码重复是,使用硬编码的方式重复实现相同的数据处理算法。我们可以考虑把规则转换为自定义注解,作为元数据对类或对字段、方法进行描述,然后通过反射动态读取这些元数据、字段或调用方法,实现规则参数和规则定义的分离。也就是说,把变化的部分也就是规则的参数放入注解,规则的定义统一处理。
第三种代码重复是业务代码中常见的DO、DTO、VO转换时大量字段的手动赋值遇到有上百个属性的复杂类型非常非常容易出错。我的建议是不要手动进行赋值考虑使用Bean映射工具进行。此外还可以考虑采用单元测试对所有字段进行赋值正确性校验。
最后,我想说的是,我会把代码重复度作为评估一个项目质量的重要指标,如果一个项目几乎没有任何重复代码,那么它内部的抽象一定是非常好的。在做项目重构的时候,你也可以以消除重复为第一目标去考虑实现。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 除了模板方法设计模式是减少重复代码的一把好手观察者模式也常用于减少代码重复并且是松耦合方式。Spring也提供了类似工具点击[这里](https://docs.spring.io/spring/docs/5.2.3.RELEASE/spring-framework-reference/core.html#context-functionality-events-annotation)查看),你能想到有哪些应用场景吗?
1. 关于Bean属性复制工具除了最简单的Spring的BeanUtils工具类的使用你还知道哪些对象映射类库吗它们又有什么功能呢
你还有哪些消除重复代码的心得和方法吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,621 @@
<audio id="audio" title="22 | 接口设计:系统间对话的语言,一定要统一" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a6/86/a6c4225460e9687b003b4b632379ec86.mp3"></audio>
你好,我是朱晔。今天,我要和你分享的主题是,在做接口设计时一定要确保系统之间对话的语言是统一的。
我们知道,开发一个服务的第一步就是设计接口。接口的设计需要考虑的点非常多,比如接口的命名、参数列表、包装结构体、接口粒度、版本策略、幂等性实现、同步异步处理方式等。
这其中,和接口设计相关比较重要的点有三个,分别是包装结构体、版本策略、同步异步处理方式。今天,我就通过我遇到的实际案例,和你一起看看因为接口设计思路和调用方理解不一致所导致的问题,以及相关的实践经验。
## 接口的响应要明确表示接口的处理结果
我曾遇到过一个处理收单的收单中心项目下单接口返回的响应体中包含了success、code、info、message等属性以及二级嵌套对象data结构体。在对项目进行重构的时候我们发现真的是无从入手接口缺少文档代码一有改动就出错。
有时候下单操作的响应结果是这样的success是true、message是OK貌似代表下单成功了但info里却提示订单存在风险code是一个5001的错误码data中能看到订单状态是Cancelled订单ID是-1好像又说明没有下单成功。
```
{
&quot;success&quot;: true,
&quot;code&quot;: 5001,
&quot;info&quot;: &quot;Risk order detected&quot;,
&quot;message&quot;: &quot;OK&quot;,
&quot;data&quot;: {
&quot;orderStatus&quot;: &quot;Cancelled&quot;,
&quot;orderId&quot;: -1
}
}
```
有些时候这个下单接口又会返回这样的结果success是falsemessage提示非法用户ID看上去下单失败但data里的orderStatus是Created、info是空、code是0。那么这次下单到底是成功还是失败呢
```
{
&quot;success&quot;: false,
&quot;code&quot;: 0,
&quot;info&quot;: &quot;&quot;,
&quot;message&quot;: &quot;Illegal userId&quot;,
&quot;data&quot;: {
&quot;orderStatus&quot;: &quot;Created&quot;,
&quot;orderId&quot;: 0
}
}
```
这样的结果,让我们非常疑惑:
- 结构体的code和HTTP响应状态码是什么关系
- success到底代表下单成功还是失败
- info和message的区别是什么
- data中永远都有数据吗什么时候应该去查询data
造成如此混乱的原因是这个收单服务本身并不真正处理下单操作只是做一些预校验和预处理真正的下单操作需要在收单服务内部调用另一个订单服务来处理订单服务处理完成后会返回订单状态和ID。
在一切正常的情况下下单后的订单状态就是已创建Created订单ID是一个大于0的数字。而结构体中的message和success其实是收单服务的处理异常信息和处理成功与否的结果code、info是调用订单服务的结果。
对于第一次调用收单服务自己没问题success是truemessage是OK但调用订单服务时却因为订单风险问题被拒绝所以code是5001info是Risk order detecteddata中的信息是订单服务返回的所以最终订单状态是Cancelled。
对于第二次调用因为用户ID非法所以收单服务在校验了参数后直接就返回了success是falsemessage是Illegal userId。因为请求没有到订单服务所以info、code、data都是默认值订单状态的默认值是Created。因此第二次下单肯定失败了但订单状态却是已创建。
可以看到,如此混乱的接口定义和实现方式,是无法让调用者分清到底应该怎么处理的。**为了将接口设计得更合理,我们需要考虑如下两个原则:**
- 对外隐藏内部实现。虽然说收单服务调用订单服务进行真正的下单操作,但是直接接口其实是收单服务提供的,收单服务不应该“直接”暴露其背后订单服务的状态码、错误描述。
- 设计接口结构时,明确每个字段的含义,以及客户端的处理方式。
基于这两个原则我们调整一下返回结构体去掉外层的info即不再把订单服务的调用结果告知客户端
```
@Data
public class APIResponse&lt;T&gt; {
private boolean success;
private T data;
private int code;
private String message;
}
```
并明确接口的设计逻辑:
- 如果出现非200的HTTP响应状态码就代表请求没有到收单服务可能是网络出问题、网络超时或者网络配置的问题。这时肯定无法拿到服务端的响应体客户端可以给予友好提示比如让用户重试不需要继续解析响应结构体。
- 如果HTTP响应码是200解析响应体查看success为false代表下单请求处理失败可能是因为收单服务参数验证错误也可能是因为订单服务下单操作失败。这时根据收单服务定义的错误码表和code做不同处理。比如友好提示或是让用户重新填写相关信息其中友好提示的文字内容可以从message中获取。
<li>success为true的情况下才需要继续解析响应体中的data结构体。data结构体代表了业务数据通常会有下面两种情况。
<ul>
- 通常情况下success为true时订单状态是Created获取orderId属性可以拿到订单号。
- 特殊情况下比如收单服务内部处理不当或是订单服务出现了额外的状态虽然success为true但订单实际状态不是Created这时可以给予友好的错误提示。
<img src="https://static001.geekbang.org/resource/image/cd/ed/cd799f2bdb407bcb9ff5ad452376a6ed.jpg" alt="">
明确了接口的设计逻辑,我们就是可以实现收单服务的服务端和客户端来模拟这些情况了。
首先,实现服务端的逻辑:
```
@GetMapping(&quot;server&quot;)
public APIResponse&lt;OrderInfo&gt; server(@RequestParam(&quot;userId&quot;) Long userId) {
APIResponse&lt;OrderInfo&gt; response = new APIResponse&lt;&gt;();
if (userId == null) {
//对于userId为空的情况收单服务直接处理失败给予相应的错误码和错误提示
response.setSuccess(false);
response.setCode(3001);
response.setMessage(&quot;Illegal userId&quot;);
} else if (userId == 1) {
//对于userId=1的用户模拟订单服务对于风险用户的情况
response.setSuccess(false);
//把订单服务返回的错误码转换为收单服务错误码
response.setCode(3002);
response.setMessage(&quot;Internal Error, order is cancelled&quot;);
//同时日志记录内部错误
log.warn(&quot;用户 {} 调用订单服务失败,原因是 Risk order detected&quot;, userId);
} else {
//其他用户,下单成功
response.setSuccess(true);
response.setCode(2000);
response.setMessage(&quot;OK&quot;);
response.setData(new OrderInfo(&quot;Created&quot;, 2L));
}
return response;
}
```
客户端代码,则可以按照流程图上的逻辑来实现,同样模拟三种出错情况和正常下单的情况:
- error==1的用例模拟一个不存在的URL请求无法到收单服务会得到404的HTTP状态码直接进行友好提示这是第一层处理。
<img src="https://static001.geekbang.org/resource/image/c1/36/c1ddea0ebf6d86956d68efb0424a6b36.png" alt="">
- error==2的用例模拟userId参数为空的情况收单服务会因为缺少userId参数提示非法用户。这时可以把响应体中的message展示给用户这是第二层处理。
<img src="https://static001.geekbang.org/resource/image/f3/47/f36d21beb95ce0e7ea96dfde96f21847.png" alt="">
- error==3的用例模拟userId为1的情况因为用户有风险收单服务调用订单服务出错。处理方式和之前没有任何区别因为收单服务会屏蔽订单服务的内部错误。
<img src="https://static001.geekbang.org/resource/image/41/2c/412c64e66a574d8252ac8dd59b4cfe2c.png" alt="">
但在服务端可以看到如下错误信息:
```
[14:13:13.951] [http-nio-45678-exec-8] [WARN ] [.c.a.d.APIThreeLevelStatusController:36 ] - 用户 1 调用订单服务失败,原因是 Risk order detected
```
- error==0的用例模拟正常用户下单成功。这时可以解析data结构体提取业务结果作为兜底需要判断订单状态如果不是Created则给予友好提示否则查询orderId获得下单的订单号这是第三层处理。
<img src="https://static001.geekbang.org/resource/image/f5/48/f57ae156de7592de167bd09aaadb8348.png" alt="">
客户端的实现代码如下:
```
@GetMapping(&quot;client&quot;)
public String client(@RequestParam(value = &quot;error&quot;, defaultValue = &quot;0&quot;) int error) {
String url = Arrays.asList(&quot;http://localhost:45678/apiresposne/server?userId=2&quot;,
&quot;http://localhost:45678/apiresposne/server2&quot;,
&quot;http://localhost:45678/apiresposne/server?userId=&quot;,
&quot;http://localhost:45678/apiresposne/server?userId=1&quot;).get(error);
//第一层先看状态码如果状态码不是200不处理响应体
String response = &quot;&quot;;
try {
response = Request.Get(url).execute().returnContent().asString();
} catch (HttpResponseException e) {
log.warn(&quot;请求服务端出现返回非200&quot;, e);
return &quot;服务器忙,请稍后再试!&quot;;
} catch (IOException e) {
e.printStackTrace();
}
//状态码为200的情况下处理响应体
if (!response.equals(&quot;&quot;)) {
try {
APIResponse&lt;OrderInfo&gt; apiResponse = objectMapper.readValue(response, new TypeReference&lt;APIResponse&lt;OrderInfo&gt;&gt;() {
});
//第二层success是false直接提示用户
if (!apiResponse.isSuccess()) {
return String.format(&quot;创建订单失败,请稍后再试,错误代码: %s 错误原因:%s&quot;, apiResponse.getCode(), apiResponse.getMessage());
} else {
//第三层往下解析OrderInfo
OrderInfo orderInfo = apiResponse.getData();
if (&quot;Created&quot;.equals(orderInfo.getStatus()))
return String.format(&quot;创建订单成功,订单号是:%s状态是%s&quot;, orderInfo.getOrderId(), orderInfo.getStatus());
else
return String.format(&quot;创建订单失败,请联系客服处理&quot;);
}
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
return &quot;&quot;;
}
```
**相比原来混乱的接口定义和处理逻辑,改造后的代码,明确了接口每一个字段的含义,以及对于各种情况服务端的输出和客户端的处理步骤,对齐了客户端和服务端的处理逻辑**。那么现在你能回答前面那4个让人疑惑的问题了吗
最后分享一个小技巧。为了简化服务端代码我们可以把包装API响应体APIResponse的工作交由框架自动完成这样直接返回DTO OrderInfo即可。对于业务逻辑错误可以抛出一个自定义异常
```
@GetMapping(&quot;server&quot;)
public OrderInfo server(@RequestParam(&quot;userId&quot;) Long userId) {
if (userId == null) {
throw new APIException(3001, &quot;Illegal userId&quot;);
}
if (userId == 1) {
...
//直接抛出异常
throw new APIException(3002, &quot;Internal Error, order is cancelled&quot;);
}
//直接返回DTO
return new OrderInfo(&quot;Created&quot;, 2L);
}
```
在APIException中包含错误码和错误消息
```
public class APIException extends RuntimeException {
@Getter
private int errorCode;
@Getter
private String errorMessage;
public APIException(int errorCode, String errorMessage) {
super(errorMessage);
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
public APIException(Throwable cause, int errorCode, String errorMessage) {
super(errorMessage, cause);
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
}
```
然后,定义一个@RestControllerAdvice来完成自动包装响应体的工作
1. 通过实现ResponseBodyAdvice接口的beforeBodyWrite方法来处理成功请求的响应体转换。
1. 实现一个@ExceptionHandler来处理业务异常时APIException到APIResponse的转换。
```
//此段代码只是Demo生产级应用还需要扩展很多细节
@RestControllerAdvice
@Slf4j
public class APIResponseAdvice implements ResponseBodyAdvice&lt;Object&gt; {
//自动处理APIException包装为APIResponse
@ExceptionHandler(APIException.class)
public APIResponse handleApiException(HttpServletRequest request, APIException ex) {
log.error(&quot;process url {} failed&quot;, request.getRequestURL().toString(), ex);
APIResponse apiResponse = new APIResponse();
apiResponse.setSuccess(false);
apiResponse.setCode(ex.getErrorCode());
apiResponse.setMessage(ex.getErrorMessage());
return apiResponse;
}
//仅当方法或类没有标记@NoAPIResponse才自动包装
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return returnType.getParameterType() != APIResponse.class
&amp;&amp; AnnotationUtils.findAnnotation(returnType.getMethod(), NoAPIResponse.class) == null
&amp;&amp; AnnotationUtils.findAnnotation(returnType.getDeclaringClass(), NoAPIResponse.class) == null;
}
//自动包装外层APIResposne响应
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
APIResponse apiResponse = new APIResponse();
apiResponse.setSuccess(true);
apiResponse.setMessage(&quot;OK&quot;);
apiResponse.setCode(2000);
apiResponse.setData(body);
return apiResponse;
}
}
```
在这里,我们实现了一个@NoAPIResponse自定义注解。如果某些@RestController的接口不希望实现自动包装的话,可以标记这个注解:
```
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoAPIResponse {
}
```
在ResponseBodyAdvice的support方法中我们排除了标记有这个注解的方法或类的自动响应体包装。比如对于刚才我们实现的测试客户端client方法不需要包装为APIResponse就可以标记上这个注解
```
@GetMapping(&quot;client&quot;)
@NoAPIResponse
public String client(@RequestParam(value = &quot;error&quot;, defaultValue = &quot;0&quot;) int error)
```
这样我们的业务逻辑中就不需要考虑响应体的包装,代码会更简洁。
## 要考虑接口变迁的版本控制策略
接口不可能一成不变,需要根据业务需求不断增加内部逻辑。如果做大的功能调整或重构,涉及参数定义的变化或是参数废弃,导致接口无法向前兼容,这时接口就需要有版本的概念。在考虑接口版本策略设计时,我们需要注意的是,最好一开始就明确版本策略,并考虑在整个服务端统一版本策略。
**第一,版本策略最好一开始就考虑。**
既然接口总是要变迁的那么最好一开始就确定版本策略。比如确定是通过URL Path实现是通过QueryString实现还是通过HTTP头实现。这三种实现方式的代码如下
```
//通过URL Path实现版本控制
@GetMapping(&quot;/v1/api/user&quot;)
public int right1(){
return 1;
}
//通过QueryString中的version参数实现版本控制
@GetMapping(value = &quot;/api/user&quot;, params = &quot;version=2&quot;)
public int right2(@RequestParam(&quot;version&quot;) int version) {
return 2;
}
//通过请求头中的X-API-VERSION参数实现版本控制
@GetMapping(value = &quot;/api/user&quot;, headers = &quot;X-API-VERSION=3&quot;)
public int right3(@RequestHeader(&quot;X-API-VERSION&quot;) int version) {
return 3;
}
```
这样,客户端就可以在配置中处理相关版本控制的参数,有可能实现版本的动态切换。
这三种方式中URL Path的方式最直观也最不容易出错QueryString不易携带不太推荐作为公开API的版本策略HTTP头的方式比较没有侵入性如果仅仅是部分接口需要进行版本控制可以考虑这种方式。
**第二,版本实现方式要统一。**
之前我就遇到过一个O2O项目需要针对商品、商店和用户实现REST接口。虽然大家约定通过URL Path方式实现API版本控制但实现方式不统一有的是/api/item/v1有的是/api/v1/shop还有的是/v1/api/merchant
```
@GetMapping(&quot;/api/item/v1&quot;)
public void wrong1(){
}
@GetMapping(&quot;/api/v1/shop&quot;)
public void wrong2(){
}
@GetMapping(&quot;/v1/api/merchant&quot;)
public void wrong3(){
}
```
显然商品、商店和商户的接口开发同学没有按照一致的URL格式来实现接口的版本控制。更要命的是我们可能开发出两个URL类似接口比如一个是/api/v1/user另一个是/api/user/v1这到底是一个接口还是两个接口呢
相比于在每一个接口的URL Path中设置版本号更理想的方式是在框架层面实现统一。如果你使用Spring框架的话可以按照下面的方式自定义RequestMappingHandlerMapping来实现。
首先,创建一个注解来定义接口的版本。@APIVersion自定义注解可以应用于方法或Controller上
```
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface APIVersion {
String[] value();
}
```
然后定义一个APIVersionHandlerMapping类继承RequestMappingHandlerMapping。
RequestMappingHandlerMapping的作用是根据类或方法上的@RequestMapping来生成RequestMappingInfo的实例。我们覆盖registerHandlerMethod方法的实现@APIVersion自定义注解中读取版本信息拼接上原有的、不带版本号的URL Pattern构成新的RequestMappingInfo来通过注解的方式为接口增加基于URL的版本号
```
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class&lt;?&gt; beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
Class&lt;?&gt; controllerClass = method.getDeclaringClass();
//类上的APIVersion注解
APIVersion apiVersion = AnnotationUtils.findAnnotation(controllerClass, APIVersion.class);
//方法上的APIVersion注解
APIVersion methodAnnotation = AnnotationUtils.findAnnotation(method, APIVersion.class);
//以方法上的注解优先
if (methodAnnotation != null) {
apiVersion = methodAnnotation;
}
String[] urlPatterns = apiVersion == null ? new String[0] : apiVersion.value();
PatternsRequestCondition apiPattern = new PatternsRequestCondition(urlPatterns);
PatternsRequestCondition oldPattern = mapping.getPatternsCondition();
PatternsRequestCondition updatedFinalPattern = apiPattern.combine(oldPattern);
//重新构建RequestMappingInfo
mapping = new RequestMappingInfo(mapping.getName(), updatedFinalPattern, mapping.getMethodsCondition(),
mapping.getParamsCondition(), mapping.getHeadersCondition(), mapping.getConsumesCondition(),
mapping.getProducesCondition(), mapping.getCustomCondition());
super.registerHandlerMethod(handler, method, mapping);
}
}
```
最后也是特别容易忽略的一点要通过实现WebMvcRegistrations接口来生效自定义的APIVersionHandlerMapping
```
@SpringBootApplication
public class CommonMistakesApplication implements WebMvcRegistrations {
...
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new APIVersionHandlerMapping();
}
}
```
这样就实现了在Controller上或接口方法上通过注解来实现以统一的Pattern进行版本号控制
```
@GetMapping(value = &quot;/api/user&quot;)
@APIVersion(&quot;v4&quot;)
public int right4() {
return 4;
}
```
加上注解后,访问浏览器查看效果:
<img src="https://static001.geekbang.org/resource/image/f8/02/f8fae105eae532e93e329ae2d3253502.png" alt="">
使用框架来明确API版本的指定策略不仅实现了标准化更实现了强制的API版本控制。对上面代码略做修改我们就可以实现不设置@APIVersion接口就给予报错提示
## 接口处理方式要明确同步还是异步
看到这个标题,你可能感觉不太好理解,我们直接看一个实际案例吧。
有一个文件上传服务FileService其中一个upload文件上传接口特别慢原因是这个上传接口在内部需要进行两步操作首先上传原图然后压缩后上传缩略图。如果每一步都耗时5秒的话那么这个接口返回至少需要10秒的时间。
于是,开发同学把接口改为了异步处理,每一步操作都限定了超时时间,也就是分别把上传原文件和上传缩略图的操作提交到线程池,然后等待一定的时间:
```
private ExecutorService threadPool = Executors.newFixedThreadPool(2);
//我没有贴出两个文件上传方法uploadFile和uploadThumbnailFile的实现它们在内部只是随机进行休眠然后返回文件名对于本例来说不是很重要
public UploadResponse upload(UploadRequest request) {
UploadResponse response = new UploadResponse();
//上传原始文件任务提交到线程池处理
Future&lt;String&gt; uploadFile = threadPool.submit(() -&gt; uploadFile(request.getFile()));
//上传缩略图任务提交到线程池处理
Future&lt;String&gt; uploadThumbnailFile = threadPool.submit(() -&gt; uploadThumbnailFile(request.getFile()));
//等待上传原始文件任务完成最多等待1秒
try {
response.setDownloadUrl(uploadFile.get(1, TimeUnit.SECONDS));
} catch (Exception e) {
e.printStackTrace();
}
//等待上传缩略图任务完成最多等待1秒
try {
response.setThumbnailDownloadUrl(uploadThumbnailFile.get(1, TimeUnit.SECONDS));
} catch (Exception e) {
e.printStackTrace();
}
return response;
}
```
上传接口的请求和响应比较简单,传入二进制文件,传出原文件和缩略图下载地址:
```
@Data
public class UploadRequest {
private byte[] file;
}
@Data
public class UploadResponse {
private String downloadUrl;
private String thumbnailDownloadUrl;
}
```
到这里,你能看出这种实现方式的问题是什么吗?
从接口命名上看虽然是同步上传操作,但其内部通过线程池进行异步上传,并因为设置了较短超时所以接口整体响应挺快。但是,**一旦遇到超时,接口就不能返回完整的数据,不是无法拿到原文件下载地址,就是无法拿到缩略图下载地址,接口的行为变得不可预测**
<img src="https://static001.geekbang.org/resource/image/8e/78/8e75863413fd7a01514b47804f0c4a78.png" alt="">
所以,这种优化接口响应速度的方式并不可取,**更合理的方式是,让上传接口要么是彻底的同步处理,要么是彻底的异步处理**
- 所谓同步处理,接口一定是同步上传原文件和缩略图的,调用方可以自己选择调用超时,如果来得及可以一直等到上传完成,如果等不及可以结束等待,下一次再重试;
- 所谓异步处理接口是两段式的上传接口本身只是返回一个任务ID然后异步做上传操作上传接口响应很快客户端需要之后再拿着任务ID调用任务查询接口查询上传的文件URL。
同步上传接口的实现代码如下,把超时的选择留给客户端:
```
public SyncUploadResponse syncUpload(SyncUploadRequest request) {
SyncUploadResponse response = new SyncUploadResponse();
response.setDownloadUrl(uploadFile(request.getFile()));
response.setThumbnailDownloadUrl(uploadThumbnailFile(request.getFile()));
return response;
}
```
这里的SyncUploadRequest和SyncUploadResponse类与之前定义的UploadRequest和UploadResponse是一致的。对于接口的入参和出参DTO的命名我比较建议的方式是使用接口名+Request和Response后缀。
接下来我们看看异步的上传文件接口如何实现。异步上传接口在出参上有点区别不再返回文件URL而是返回一个任务ID
```
@Data
public class AsyncUploadRequest {
private byte[] file;
}
@Data
public class AsyncUploadResponse {
private String taskId;
}
```
在接口实现上我们同样把上传任务提交到线程池处理但是并不会同步等待任务完成而是完成后把结果写入一个HashMap任务查询接口通过查询这个HashMap来获得文件的URL
```
//计数器作为上传任务的ID
private AtomicInteger atomicInteger = new AtomicInteger(0);
//暂存上传操作的结果,生产代码需要考虑数据持久化
private ConcurrentHashMap&lt;String, SyncQueryUploadTaskResponse&gt; downloadUrl = new ConcurrentHashMap&lt;&gt;();
//异步上传操作
public AsyncUploadResponse asyncUpload(AsyncUploadRequest request) {
AsyncUploadResponse response = new AsyncUploadResponse();
//生成唯一的上传任务ID
String taskId = &quot;upload&quot; + atomicInteger.incrementAndGet();
//异步上传操作只返回任务ID
response.setTaskId(taskId);
//提交上传原始文件操作到线程池异步处理
threadPool.execute(() -&gt; {
String url = uploadFile(request.getFile());
//如果ConcurrentHashMap不包含Key则初始化一个SyncQueryUploadTaskResponse然后设置DownloadUrl
downloadUrl.computeIfAbsent(taskId, id -&gt; new SyncQueryUploadTaskResponse(id)).setDownloadUrl(url);
});
//提交上传缩略图操作到线程池异步处理
threadPool.execute(() -&gt; {
String url = uploadThumbnailFile(request.getFile());
downloadUrl.computeIfAbsent(taskId, id -&gt; new SyncQueryUploadTaskResponse(id)).setThumbnailDownloadUrl(url);
});
return response;
}
```
文件上传查询接口则以任务ID作为入参返回两个文件的下载地址因为文件上传查询接口是同步的所以直接命名为syncQueryUploadTask
```
//syncQueryUploadTask接口入参
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskRequest {
private final String taskId;//使用上传文件任务ID查询上传结果
}
//syncQueryUploadTask接口出参
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskResponse {
private final String taskId; //任务ID
private String downloadUrl; //原始文件下载URL
private String thumbnailDownloadUrl; //缩略图下载URL
}
public SyncQueryUploadTaskResponse syncQueryUploadTask(SyncQueryUploadTaskRequest request) {
SyncQueryUploadTaskResponse response = new SyncQueryUploadTaskResponse(request.getTaskId());
//从之前定义的downloadUrl ConcurrentHashMap查询结果
response.setDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getDownloadUrl());
response.setThumbnailDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getThumbnailDownloadUrl());
return response;
}
```
经过改造的FileService不再提供一个看起来是同步上传内部却是异步上传的upload方法改为提供很明确的
- 同步上传接口syncUpload
- 异步上传接口asyncUpload搭配syncQueryUploadTask查询上传结果。
使用方可以根据业务性质选择合适的方法:如果是后端批处理使用,那么可以使用同步上传,多等待一些时间问题不大;如果是面向用户的接口,那么接口响应时间不宜过长,可以调用异步上传接口,然后定时轮询上传结果,拿到结果再显示。
## 重点回顾
今天,我针对接口设计,和你深入探讨了三个方面的问题。
第一,针对响应体的设计混乱、响应结果的不明确问题,服务端需要明确响应体每一个字段的意义,以一致的方式进行处理,并确保不透传下游服务的错误。
第二,针对接口版本控制问题,主要就是在开发接口之前明确版本控制策略,以及尽量使用统一的版本控制策略两方面。
第三针对接口的处理方式我认为需要明确要么是同步要么是异步。如果API列表中既有同步接口也有异步接口那么最好直接在接口名中明确。
一个良好的接口文档不仅仅需要说明如何调用接口更需要补充接口使用的最佳实践以及接口的SLA标准。我看到的大部分接口文档只给出了参数定义但诸如幂等性、同步异步、缓存策略等看似内部实现相关的一些设计其实也会影响调用方对接口的使用策略最好也可以体现在接口文档中。
最后我再额外提一下对于服务端出错的时候是否返回200响应码的问题其实一直有争论。从RESTful设计原则来看我们应该尽量利用HTTP状态码来表达错误但也不是这么绝对。
如果我们认为HTTP 状态码是协议层面的履约那么当这个错误已经不涉及HTTP协议时换句话说服务端已经收到请求进入服务端业务处理后产生的错误不一定需要硬套协议本身的错误码。但涉及非法URL、非法参数、没有权限等无法处理请求的情况还是应该使用正确的响应码来应对。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在第一节的例子中接口响应结构体中的code字段代表执行结果的错误码对于业务特别复杂的接口可能会有很多错误情况code可能会有几十甚至几百个。客户端开发人员需要根据每一种错误情况逐一写if-else进行不同交互处理会非常麻烦你觉得有什么办法来改进吗作为服务端是否有必要告知客户端接口执行的错误码呢
1. 在第二节的例子中,我们在类或方法上标记@APIVersion自定义注解实现了URL方式统一的接口版本定义。你可以用类似的方式也就是自定义RequestMappingHandlerMapping来实现一套统一的基于请求头方式的版本控制吗
关于接口设计,你还遇到过其他问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,419 @@
<audio id="audio" title="23 | 缓存设计:缓存可以锦上添花也可以落井下石" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/10/b061b9c1ac00c39dba63e3795032f910.mp3"></audio>
你好,我是朱晔。今天,我从设计的角度,与你聊聊缓存。
通常我们会使用更快的介质比如内存作为缓存来解决较慢介质比如磁盘读取数据慢的问题缓存是用空间换时间来解决性能问题的一种架构设计模式。更重要的是磁盘上存储的往往是原始数据而缓存中保存的可以是面向呈现的数据。这样一来缓存不仅仅是加快了IO还可以减少原始数据的计算工作。
此外缓存系统一般设计简单功能相对单一所以诸如Redis这种缓存系统的整体吞吐量能达到关系型数据库的几倍甚至几十倍因此缓存特别适用于互联网应用的高并发场景。
使用Redis做缓存虽然简单好用但使用和设计缓存并不是set一下这么简单需要注意缓存的同步、雪崩、并发、穿透等问题。今天我们就来详细聊聊。
## 不要把Redis当作数据库
通常我们会使用Redis等分布式缓存数据库来缓存数据但是**千万别把Redis当做数据库来使用。**我就见过许多案例因为Redis中数据消失导致业务逻辑错误并且因为没有保留原始数据业务都无法恢复。
Redis的确具有数据持久化功能可以实现服务重启后数据不丢失。这一点很容易让我们误认为Redis可以作为高性能的KV数据库。
其实从本质上来看Redis免费版是一个内存数据库所有数据保存在内存中并且直接从内存读写数据响应操作只不过具有数据持久化能力。所以Redis的特点是处理请求很快但无法保存超过内存大小的数据。
>
备注VM模式虽然可以保存超过内存大小的数据但是因为性能原因从2.6开始已经被废弃。此外Redis企业版提供了Redis on Flash可以实现Key+字典+热数据保存在内存中冷数据保存在SSD中。
因此把Redis用作缓存我们需要注意两点。
第一从客户端的角度来说缓存数据的特点一定是有原始数据来源且允许丢失即使设置的缓存时间是1分钟在30秒时缓存数据因为某种原因消失了我们也要能接受。当数据丢失后我们需要从原始数据重新加载数据不能认为缓存系统是绝对可靠的更不能认为缓存系统不会删除没有过期的数据。
第二从Redis服务端的角度来说缓存系统可以保存的数据量一定是小于原始数据的。首先我们应该限制Redis对内存的使用量也就是设置maxmemory参数其次我们应该根据数据特点明确Redis应该以怎样的算法来驱逐数据。
从[Redis的文档](https://redis.io/topics/lru-cache)可以看到,常用的数据淘汰策略有:
- allkeys-lru针对所有Key优先删除最近最少使用的Key
- volatile-lru针对带有过期时间的Key优先删除最近最少使用的Key
- volatile-ttl针对带有过期时间的Key优先删除即将过期的Key根据TTL的值
- allkeys-lfuRedis 4.0以上针对所有Key优先删除最少使用的Key
- volatile-lfuRedis 4.0以上针对带有过期时间的Key优先删除最少使用的Key。
其实这些算法是Key范围+Key选择算法的搭配组合其中范围有allkeys和volatile两种算法有LRU、TTL和LFU三种。接下来我就从Key范围和算法角度和你说说如何选择合适的驱逐算法。
首先从算法角度来说Redis 4.0以后推出的LFU比LRU更“实用”。试想一下如果一个Key访问频率是1天一次但正好在1秒前刚访问过那么LRU可能不会选择优先淘汰这个Key反而可能会淘汰一个5秒访问一次但最近2秒没有访问过的Key而LFU算法不会有这个问题。而TTL会比较“头脑简单”一点优先删除即将过期的Key但有可能这个Key正在被大量访问。
然后从Key范围角度来说allkeys可以确保即使Key没有TTL也能回收如果使用的时候客户端总是“忘记”设置缓存的过期时间那么可以考虑使用这个系列的算法。而volatile会更稳妥一些万一客户端把Redis当做了长效缓存使用只是启动时候初始化一次缓存那么一旦删除了此类没有TTL的数据可能就会导致客户端出错。
所以不管是使用者还是管理者都要考虑Redis的使用方式使用者需要考虑应该以缓存的姿势来使用Redis管理者应该为Redis设置内存限制和合适的驱逐策略避免出现OOM。
## 注意缓存雪崩问题
由于缓存系统的IOPS比数据库高很多因此要特别小心短时间内大量缓存失效的情况。这种情况一旦发生可能就会在瞬间有大量的数据需要回源到数据库查询对数据库造成极大的压力极限情况下甚至导致后端数据库直接崩溃。**这就是我们常说的缓存失效,也叫作缓存雪崩**。
从广义上说,产生缓存雪崩的原因有两种:
- 第一种是,缓存系统本身不可用,导致大量请求直接回源到数据库;
- 第二种是应用设计层面大量的Key在同一时间过期导致大量的数据回源。
第一种原因主要涉及缓存系统本身高可用的配置不属于缓存设计层面的问题所以今天我主要和你说说如何确保大量Key不在同一时间被动过期。
程序初始化的时候放入1000条城市数据到Redis缓存中过期时间是30秒数据过期后从数据库获取数据然后写入缓存每次从数据库获取数据后计数器+1在程序启动的同时启动一个定时任务线程每隔一秒输出计数器的值并把计数器归零。
压测一个随机查询某城市信息的接口观察一下数据库的QPS
```
@Autowired
private StringRedisTemplate stringRedisTemplate;
private AtomicInteger atomicInteger = new AtomicInteger();
@PostConstruct
public void wrongInit() {
//初始化1000个城市数据到Redis所有缓存数据有效期30秒
IntStream.rangeClosed(1, 1000).forEach(i -&gt; stringRedisTemplate.opsForValue().set(&quot;city&quot; + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
log.info(&quot;Cache init finished&quot;);
//每秒一次输出数据库访问的QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -&gt; {
log.info(&quot;DB QPS : {}&quot;, atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
@GetMapping(&quot;city&quot;)
public String city() {
//随机查询一个城市
int id = ThreadLocalRandom.current().nextInt(1000) + 1;
String key = &quot;city&quot; + id;
String data = stringRedisTemplate.opsForValue().get(key);
if (data == null) {
//回源到数据库查询
data = getCityFromDb(id);
if (!StringUtils.isEmpty(data))
//缓存30秒过期
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
return data;
}
private String getCityFromDb(int cityId) {
//模拟查询数据库,查一次增加计数器加一
atomicInteger.incrementAndGet();
return &quot;citydata&quot; + System.currentTimeMillis();
}
```
使用wrk工具设置10线程10连接压测city接口
```
wrk -c10 -t10 -d 100s http://localhost:45678/cacheinvalid/city
```
启动程序30秒后缓存过期回源的数据库QPS最高达到了700多
<img src="https://static001.geekbang.org/resource/image/91/6b/918a91e34725e475cdee746d5ba8aa6b.png" alt="">
解决缓存Key同时大规模失效需要回源导致数据库压力激增问题的方式有两种。
方案一差异化缓存过期时间不要让大量的Key在同一时间过期。比如在初始化缓存的时候设置缓存的过期时间是30秒+10秒以内的随机延迟扰动值。这样这些Key不会集中在30秒这个时刻过期而是会分散在30~40秒之间过期
```
@PostConstruct
public void rightInit1() {
//这次缓存的过期时间是30秒+10秒内的随机延迟
IntStream.rangeClosed(1, 1000).forEach(i -&gt; stringRedisTemplate.opsForValue().set(&quot;city&quot; + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
log.info(&quot;Cache init finished&quot;);
//同样1秒一次输出数据库QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -&gt; {
log.info(&quot;DB QPS : {}&quot;, atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
```
修改后缓存过期时的回源不会集中在同一秒数据库的QPS从700多降到了最高100左右
<img src="https://static001.geekbang.org/resource/image/6f/35/6f4a666cf48c4d1373aead40afb57a35.png" alt="">
方案二让缓存不主动过期。初始化缓存数据的时候设置缓存永不过期然后启动一个后台线程30秒一次定时把所有数据更新到缓存而且通过适当的休眠控制从数据库更新数据的频率降低数据库压力
```
@PostConstruct
public void rightInit2() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
//每隔30秒全量更新一次缓存
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -&gt; {
IntStream.rangeClosed(1, 1000).forEach(i -&gt; {
String data = getCityFromDb(i);
//模拟更新缓存需要一定的时间
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) { }
if (!StringUtils.isEmpty(data)) {
//缓存永不过期,被动更新
stringRedisTemplate.opsForValue().set(&quot;city&quot; + i, data);
}
});
log.info(&quot;Cache update finished&quot;);
//启动程序的时候需要等待首次更新缓存完成
countDownLatch.countDown();
}, 0, 30, TimeUnit.SECONDS);
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -&gt; {
log.info(&quot;DB QPS : {}&quot;, atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
countDownLatch.await();
}
```
这样修改后虽然缓存整体更新的耗时在21秒左右但数据库的压力会比较稳定
<img src="https://static001.geekbang.org/resource/image/5c/5a/5cb8bb1764998b57b63029bd5f69465a.png" alt="">
关于这两种解决方案,**我们需要特别注意以下三点**
- 方案一和方案二是截然不同的两种缓存方式,如果无法全量缓存所有数据,那么只能使用方案一;
- 即使使用了方案二,缓存永不过期,同样需要在查询的时候,确保有回源的逻辑。正如之前所说,我们无法确保缓存系统中的数据永不丢失。
- 不管是方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。
之前我就遇到过这样一个重大事故某系统会在缓存中对基础数据进行长达半年的缓存在某个时间点DBA把数据库中的原始数据进行了归档可以认为是删除操作。因为缓存中的数据一直在所以一开始没什么问题但半年后的一天缓存中数据过期了就从数据库中查询到了空数据加入缓存爆发了大面积的事故。
这个案例说明,缓存会让我们更不容易发现原始数据的问题,所以在把数据加入缓存之前一定要校验数据,如果发现有明显异常要及时报警。
说到这里我们再仔细看一下回源QPS超过700的截图可以看到在并发情况下总共1000条数据回源达到了1002次说明有一些条目出现了并发回源。这就是我后面要讲到的缓存并发问题。
## 注意缓存击穿问题
在某些Key属于极端热点数据且并发量很大的情况下如果这个Key过期可能会在某个瞬间出现大量的并发请求同时回源相当于大量的并发请求直接打到了数据库。**这种情况,就是我们常说的缓存击穿或缓存并发问题**。
我们来重现下这个问题。在程序启动的时候初始化一个热点数据到Redis中过期时间设置为5秒每隔1秒输出一下回源的QPS
```
@PostConstruct
public void init() {
//初始化一个热点数据到Redis中过期时间设置为5秒
stringRedisTemplate.opsForValue().set(&quot;hotsopt&quot;, getExpensiveData(), 5, TimeUnit.SECONDS);
//每隔1秒输出一下回源的QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -&gt; {
log.info(&quot;DB QPS : {}&quot;, atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
@GetMapping(&quot;wrong&quot;)
public String wrong() {
String data = stringRedisTemplate.opsForValue().get(&quot;hotsopt&quot;);
if (StringUtils.isEmpty(data)) {
data = getExpensiveData();
//重新加入缓存过期时间还是5秒
stringRedisTemplate.opsForValue().set(&quot;hotsopt&quot;, data, 5, TimeUnit.SECONDS);
}
return data;
}
```
可以看到每隔5秒数据库都有20左右的QPS
<img src="https://static001.geekbang.org/resource/image/09/99/096f2bb47939f9ca0e4bc865eb4da399.png" alt="">
如果回源操作特别昂贵那么这种并发就不能忽略不计。这时我们可以考虑使用锁机制来限制回源的并发。比如如下代码示例使用Redisson来获取一个基于Redis的分布式锁在查询数据库之前先尝试获取锁
```
@Autowired
private RedissonClient redissonClient;
@GetMapping(&quot;right&quot;)
public String right() {
String data = stringRedisTemplate.opsForValue().get(&quot;hotsopt&quot;);
if (StringUtils.isEmpty(data)) {
RLock locker = redissonClient.getLock(&quot;locker&quot;);
//获取分布式锁
if (locker.tryLock()) {
try {
data = stringRedisTemplate.opsForValue().get(&quot;hotsopt&quot;);
//双重检查因为可能已经有一个B线程过了第一次判断在等锁然后A线程已经把数据写入了Redis中
if (StringUtils.isEmpty(data)) {
//回源到数据库查询
data = getExpensiveData();
stringRedisTemplate.opsForValue().set(&quot;hotsopt&quot;, data, 5, TimeUnit.SECONDS);
}
} finally {
//别忘记释放另外注意写法获取锁后整段代码try+finally确保unlock万无一失
locker.unlock();
}
}
}
return data;
}
```
这样可以把回源到数据库的并发限制在1
<img src="https://static001.geekbang.org/resource/image/63/28/63ccde3fdf058b48431fc7c554fed828.png" alt="">
在真实的业务场景下,**不一定**要这么严格地使用双重检查分布式锁进行全局的并发限制,因为这样虽然可以把数据库回源并发降到最低,但也限制了缓存失效时的并发。可以考虑的方式是:
- 方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;
- 方案二不使用锁进行限制而是使用类似Semaphore的工具限制并发数比如限制为10这样既限制了回源并发数不至于太大又能使得一定量的线程可以同时回源。
## 注意缓存穿透问题
在之前的例子中,缓存回源的逻辑都是当缓存中查不到需要的数据时,回源到数据库查询。这里容易出现的一个漏洞是,缓存中没有数据不一定代表数据没有缓存,还有一种可能是原始数据压根就不存在。
比如下面的例子。数据库中只保存有ID介于0不含和10000包含之间的用户如果从数据库查询ID不在这个区间的用户会得到空字符串所以缓存中缓存的也是空字符串。如果使用ID=0去压接口的话从缓存中查出了空字符串认为是缓存中没有数据回源查询其实相当于每次都回源
```
@GetMapping(&quot;wrong&quot;)
public String wrong(@RequestParam(&quot;id&quot;) int id) {
String key = &quot;user&quot; + id;
String data = stringRedisTemplate.opsForValue().get(key);
//无法区分是无效用户还是缓存失效
if (StringUtils.isEmpty(data)) {
data = getCityFromDb(id);
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
return data;
}
private String getCityFromDb(int id) {
atomicInteger.incrementAndGet();
//注意只有ID介于0不含和10000包含之间的用户才是有效用户可以查询到用户信息
if (id &gt; 0 &amp;&amp; id &lt;= 10000) return &quot;userdata&quot;;
//否则返回空字符串
return &quot;&quot;;
}
```
压测后数据库的QPS达到了几千
<img src="https://static001.geekbang.org/resource/image/dc/d2/dc2ee3259dd21d55a845dc4a8b9146d2.png" alt="">
如果这种漏洞被恶意利用的话,就会对数据库造成很大的性能压力。**这就是缓存穿透**。
这里需要注意,缓存穿透和缓存击穿的区别:
- 缓存穿透是指,缓存没有起到压力缓冲的作用;
- 而缓存击穿是指,缓存失效时瞬时的并发打到数据库。
解决缓存穿透有以下两种方案。
方案一对于不存在的数据同样设置一个特殊的Value到缓存中比如当数据库中查出的用户信息为空的时候设置NODATA这样具有特殊含义的字符串到缓存中。这样下次请求缓存的时候还是可以命中缓存即直接从缓存返回结果不查询数据库
```
@GetMapping(&quot;right&quot;)
public String right(@RequestParam(&quot;id&quot;) int id) {
String key = &quot;user&quot; + id;
String data = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(data)) {
data = getCityFromDb(id);
//校验从数据库返回的数据是否有效
if (!StringUtils.isEmpty(data)) {
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
else {
//如果无效直接在缓存中设置一个NODATA这样下次查询时即使是无效用户还是可以命中缓存
stringRedisTemplate.opsForValue().set(key, &quot;NODATA&quot;, 30, TimeUnit.SECONDS);
}
}
return data;
}
```
但,这种方式可能会把大量无效的数据加入缓存中,如果担心大量无效数据占满缓存的话还可以考虑方案二,即使用布隆过滤器做前置过滤。
布隆过滤器是一种概率型数据库结构由一个很长的二进制向量和一系列随机映射函数组成。它的原理是当一个元素被加入集合时通过k个散列函数将这个元素映射成一个m位bit数组中的k个点并置为1。
检索时我们只要看看这些点是不是都是1就大概知道集合中有没有它了。如果这些点有任何一个0则被检元素一定不在如果都是1则被检元素很可能在。
原理如下图所示:
<img src="https://static001.geekbang.org/resource/image/c5/1f/c58cb0c65c37f4c1bf3aceba1c00d71f.png" alt="">
布隆过滤器不保存原始值空间效率很高平均每一个元素占用2.4字节就可以达到万分之一的误判率。这里的误判率是指,过滤器判断值存在而实际并不存在的概率。我们可以设置布隆过滤器使用更大的存储空间,来得到更小的误判率。
你可以把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次:
- 如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
- 对于极小概率的误判请求才会最终让非法Key的请求走到缓存或数据库。
要用上布隆过滤器我们可以使用Google的Guava工具包提供的BloomFilter类改造一下程序启动时初始化一个具有所有有效用户ID的、10000个元素的BloomFilter在从缓存查询数据之前调用其mightContain方法来检测用户ID是否可能存在如果布隆过滤器说值不存在那么一定是不存在的直接返回
```
private BloomFilter&lt;Integer&gt; bloomFilter;
@PostConstruct
public void init() {
//创建布隆过滤器元素数量10000期望误判率1%
bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
//填充布隆过滤器
IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}
@GetMapping(&quot;right2&quot;)
public String right2(@RequestParam(&quot;id&quot;) int id) {
String data = &quot;&quot;;
//通过布隆过滤器先判断
if (bloomFilter.mightContain(id)) {
String key = &quot;user&quot; + id;
//走缓存查询
data = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(data)) {
//走数据库查询
data = getCityFromDb(id);
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
}
return data;
}
```
对于方案二,我们需要同步所有可能存在的值并加入布隆过滤器,这是比较麻烦的地方。如果业务规则明确的话,你也可以考虑直接根据业务规则判断值是否存在。
其实,方案二可以和方案一同时使用,即将布隆过滤器前置,对于误判的情况再保存特殊值到缓存,双重保险避免无效数据查询请求打到数据库。
## 注意缓存数据同步策略
前面提到的3个案例其实都属于缓存数据过期后的被动删除。在实际情况下修改了原始数据后考虑到缓存数据更新的及时性我们可能会采用主动更新缓存的策略。这些策略可能是
- 先更新缓存,再更新数据库;
- 先更新数据库,再更新缓存;
- 先删除缓存,再更新数据库,访问的时候按需加载数据到缓存;
- 先更新数据库,再删除缓存,访问的时候按需加载数据到缓存。
那么我们应该选择哪种更新策略呢我来和你逐一分析下这4种策略
“先更新缓存再更新数据库”策略不可行。数据库设计复杂,压力集中,数据库因为超时等原因更新操作失败的可能性较大,此外还会涉及事务,很可能因为数据库更新失败,导致缓存和数据库的数据不一致。
“先更新数据库再更新缓存”策略不可行。一是如果线程A和B先后完成数据库更新但更新缓存时却是B和A的顺序那很可能会把旧数据更新到缓存中引起数据不一致二是我们不确定缓存中的数据是否会被访问不一定要把所有数据都更新到缓存中去。
“先删除缓存再更新数据库,访问的时候按需加载数据到缓存”策略也不可行。在并发的情况下,很可能删除缓存后还没来得及更新数据库,就有另一个线程先读取了旧值到缓存中,如果并发量很大的话这个概率也会很大。
**“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”策略是最好的**。虽然在极端情况下这种策略也可能出现数据不一致的问题但概率非常低基本可以忽略。举一个“极端情况”的例子比如更新数据的时间节点恰好是缓存失效的瞬间这时A先读取到了旧值随后在B操作数据库完成更新并且删除了缓存之后A再把旧值加入缓存。
需要注意的是,更新数据库后删除缓存的操作可能失败,如果失败则考虑把任务加入延迟队列进行延迟重试,确保数据可以删除,缓存可以及时更新。因为删除操作是幂等的,所以即使重复删问题也不是太大,这又是删除比更新好的一个原因。
因此,针对缓存更新更推荐的方式是,缓存中的数据不由数据更新操作主动触发,统一在需要使用的时候按需加载,数据更新后及时删除缓存中的数据即可。
## 重点回顾
今天,我主要是从设计的角度,和你分享了数据缓存的三大问题。
第一我们不能把诸如Redis的缓存数据库完全当作数据库来使用。我们不能假设缓存始终可靠也不能假设没有过期的数据必然可以被读取到需要处理好缓存的回源逻辑而且要显式设置Redis的最大内存使用和数据淘汰策略避免出现OOM的问题。
第二缓存的性能比数据库好很多我们需要考虑大量请求绕过缓存直击数据库造成数据库瘫痪的各种情况。对于缓存瞬时大面积失效的缓存雪崩问题可以通过差异化缓存过期时间解决对于高并发的缓存Key回源问题可以使用锁来限制回源并发数对于不存在的数据穿透缓存的问题可以通过布隆过滤器进行数据存在性的预判或在缓存中也设置一个值来解决。
第三,当数据库中的数据有更新的时候,需要考虑如何确保缓存中数据的一致性。我们看到,“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”的策略是最为妥当的,并且要尽量设置合适的缓存过期时间,这样即便真的发生不一致,也可以在缓存过期后数据得到及时同步。
最后,我要提醒你的是,在使用缓存系统的时候,要监控缓存系统的内存使用量、命中率、对象平均过期时间等重要指标,以便评估系统的有效性,并及时发现问题。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在聊到缓存并发问题时我们说到热点Key回源会对数据库产生的压力问题如果Key特别热的话可能缓存系统也无法承受毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用Redis来做缓存那可以把一个热点Key的缓存查询压力分散到多个Redis节点上吗
1. 大Key也是数据缓存容易出现的一个问题。如果一个Key的Value特别大那么可能会对Redis产生巨大的性能影响因为Redis是单线程模型对大Key进行查询或删除等操作可能会引起Redis阻塞甚至是高可用切换。你知道怎么查询Redis中的大Key以及如何在设计上实现大Key的拆分吗
关于缓存设计,你还遇到过哪些坑呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,659 @@
<audio id="audio" title="24 | 业务代码写完,就意味着生产就绪了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/5f/bba4f8c0f9a63525ea52000b5d11b05f.mp3"></audio>
你好,我是朱晔。今天,我们来聊聊业务代码写完,是不是就意味着生产就绪,可以直接投产了。
所谓生产就绪Production-ready是指应用开发完成要投入生产环境开发层面需要额外做的一些工作。在我看来如果应用只是开发完成了功能代码然后就直接投产那意味着应用其实在裸奔。在这种情况下遇到问题因为缺乏有效的监控导致无法排查定位问题同时很可能遇到问题我们自己都不知道需要依靠用户反馈才知道应用出了问题。
那么,生产就绪需要做哪些工作呢?我认为,以下三方面的工作最重要。
第一,**提供健康检测接口**。传统采用ping的方式对应用进行探活检测并不准确。有的时候应用的关键内部或外部依赖已经离线导致其根本无法正常工作但其对外的Web端口或管理端口是可以ping通的。我们应该提供一个专有的监控检测接口并尽可能触达一些内部组件。
第二,**暴露应用内部信息**。应用内部诸如线程池、内存队列等组件往往在应用内部扮演了重要的角色如果应用或应用框架可以对外暴露这些重要信息并加以监控那么就有可能在诸如OOM等重大问题暴露之前发现蛛丝马迹避免出现更大的问题。
第三,**建立应用指标Metrics监控**。Metrics可以翻译为度量或者指标指的是对于一些关键信息以可聚合的、数值的形式做定期统计并绘制出各种趋势图表。这里的指标监控包括两个方面一是应用内部重要组件的指标监控比如JVM的一些指标、接口的QPS等二是应用的业务数据的监控比如电商订单量、游戏在线人数等。
今天,我就通过实际案例,和你聊聊如何快速实现这三方面的工作。
## 准备工作配置Spring Boot Actuator
Spring Boot有一个Actuator模块封装了诸如健康检测、应用内部信息、Metrics指标等生产就绪的功能。今天这一讲后面的内容都是基于Actuator的因此我们需要先完成Actuator的引入和配置。
我们可以像这样在pom中通过添加依赖的方式引入Actuator
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-actuator&lt;/artifactId&gt;
&lt;/dependency&gt;
```
之后你就可以直接使用Actuator了但还要注意一些重要的配置
- 如果你不希望Web应用的Actuator管理端口和应用端口重合的话可以使用management.server.port设置独立的端口。
- Actuator自带了很多开箱即用提供信息的端点Endpoint可以通过JMX或Web两种方式进行暴露。考虑到有些信息比较敏感这些内置的端点默认不是完全开启的你可以通过[官网](https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html#production-ready-endpoints-exposing-endpoints)查看这些默认值。在这里为了方便后续Demo我们设置所有端点通过Web方式开启。
- 默认情况下Actuator的Web访问方式的根地址为/actuator可以通过management.endpoints.web.base-path参数进行修改。我来演示下如何将其修改为/admin。
```
management.server.port=45679
management.endpoints.web.exposure.include=*
management.endpoints.web.base-path=/admin
```
现在,你就可以访问 [http://localhost:45679/admin](http://localhost:45679/admin) 来查看Actuator的所有功能URL了
<img src="https://static001.geekbang.org/resource/image/42/4b/420d5b3d9c10934e380e555c2347834b.png" alt="">
其中大部分端点提供的是只读信息比如查询Spring的Bean、ConfigurableEnvironment、定时任务、SpringBoot自动配置、Spring MVC映射等少部分端点还提供了修改功能比如优雅关闭程序、下载线程Dump、下载堆Dump、修改日志级别等。
你可以访问[这里](https://docs.spring.io/spring-boot/docs/2.2.4.RELEASE/actuator-api//html/)查看所有这些端点的功能详细了解它们提供的信息以及实现的操作。此外我再分享一个不错的Spring Boot管理工具[Spring Boot Admin](https://github.com/codecentric/spring-boot-admin)它把大部分Actuator端点提供的功能封装为了Web UI。
## 健康检测需要触达关键组件
在这一讲开始我们提到健康检测接口可以让监控系统或发布工具知晓应用的真实健康状态比ping应用端口更可靠。不过要达到这种效果最关键的是我们能确保健康检测接口可以探查到关键组件的状态。
好在Spring Boot Actuator帮我们预先实现了诸如数据库、InfluxDB、Elasticsearch、Redis、RabbitMQ等三方系统的健康检测指示器HealthIndicator。
通过Spring Boot的自动配置这些指示器会自动生效。当这些组件有问题的时候HealthIndicator会返回DOWN或OUT_OF_SERVICE状态health端点HTTP响应状态码也会变为503我们可以以此来配置程序健康状态监控报警。
为了演示我们可以修改配置文件把management.endpoint.health.show-details参数设置为always让所有用户都可以直接查看各个组件的健康情况如果配置为when-authorized那么可以结合management.endpoint.health.roles配置授权的角色
```
management.endpoint.health.show-details=always
```
访问health端点可以看到数据库、磁盘、RabbitMQ、Redis等组件健康状态是UP整个应用的状态也是UP
<img src="https://static001.geekbang.org/resource/image/3c/be/3c98443ebb76b65c4231aa35086dc8be.png" alt="">
在了解了基本配置之后我们考虑一下如果程序依赖一个很重要的三方服务我们希望这个服务无法访问的时候应用本身的健康状态也是DOWN。
比如三方服务有一个user接口出现异常的概率是50%
```
@Slf4j
@RestController
@RequestMapping(&quot;user&quot;)
public class UserServiceController {
@GetMapping
public User getUser(@RequestParam(&quot;userId&quot;) long id) {
//一半概率返回正确响应,一半概率抛异常
if (ThreadLocalRandom.current().nextInt() % 2 == 0)
return new User(id, &quot;name&quot; + id);
else
throw new RuntimeException(&quot;error&quot;);
}
}
```
要实现这个user接口是否正确响应和程序整体的健康状态挂钩的话很简单只需定义一个UserServiceHealthIndicator实现HealthIndicator接口即可。
在health方法中我们通过RestTemplate来访问这个user接口如果结果正确则返回Health.up()并把调用执行耗时和结果作为补充信息加入Health对象中。如果调用接口出现异常则返回Health.down()并把异常信息作为补充信息加入Health对象中
```
@Component
@Slf4j
public class UserServiceHealthIndicator implements HealthIndicator {
@Autowired
private RestTemplate restTemplate;
@Override
public Health health() {
long begin = System.currentTimeMillis();
long userId = 1L;
User user = null;
try {
//访问远程接口
user = restTemplate.getForObject(&quot;http://localhost:45678/user?userId=&quot; + userId, User.class);
if (user != null &amp;&amp; user.getUserId() == userId) {
//结果正确返回UP状态补充提供耗时和用户信息
return Health.up()
.withDetail(&quot;user&quot;, user)
.withDetail(&quot;took&quot;, System.currentTimeMillis() - begin)
.build();
} else {
//结果不正确返回DOWN状态补充提供耗时
return Health.down().withDetail(&quot;took&quot;, System.currentTimeMillis() - begin).build();
}
} catch (Exception ex) {
//出现异常先记录异常然后返回DOWN状态补充提供异常信息和耗时
log.warn(&quot;health check failed!&quot;, ex);
return Health.down(ex).withDetail(&quot;took&quot;, System.currentTimeMillis() - begin).build();
}
}
}
```
我们再来看一个聚合多个HealthIndicator的案例也就是定义一个CompositeHealthContributor来聚合多个HealthContributor实现一组线程池的监控。
首先在ThreadPoolProvider中定义两个线程池其中demoThreadPool是包含一个工作线程的线程池类型是ArrayBlockingQueue阻塞队列的长度为10还有一个ioThreadPool模拟IO操作线程池核心线程数10最大线程数50
```
public class ThreadPoolProvider {
//一个工作线程的线程池队列长度10
private static ThreadPoolExecutor demoThreadPool = new ThreadPoolExecutor(
1, 1,
2, TimeUnit.SECONDS,
new ArrayBlockingQueue&lt;&gt;(10),
new ThreadFactoryBuilder().setNameFormat(&quot;demo-threadpool-%d&quot;).get());
//核心线程数10最大线程数50的线程池队列长度50
private static ThreadPoolExecutor ioThreadPool = new ThreadPoolExecutor(
10, 50,
2, TimeUnit.SECONDS,
new ArrayBlockingQueue&lt;&gt;(100),
new ThreadFactoryBuilder().setNameFormat(&quot;io-threadpool-%d&quot;).get());
public static ThreadPoolExecutor getDemoThreadPool() {
return demoThreadPool;
}
public static ThreadPoolExecutor getIOThreadPool() {
return ioThreadPool;
}
}
```
然后我们定义一个接口来把耗时很长的任务提交到这个demoThreadPool线程池以模拟线程池队列满的情况
```
@GetMapping(&quot;slowTask&quot;)
public void slowTask() {
ThreadPoolProvider.getDemoThreadPool().execute(() -&gt; {
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
}
});
}
```
做了这些准备工作后让我们来真正实现自定义的HealthIndicator类用于单一线程池的健康状态。
我们可以传入一个ThreadPoolExecutor通过判断队列剩余容量来确定这个组件的健康状态有剩余量则返回UP否则返回DOWN并把线程池队列的两个重要数据也就是当前队列元素个数和剩余量作为补充信息加入Health
```
public class ThreadPoolHealthIndicator implements HealthIndicator {
private ThreadPoolExecutor threadPool;
public ThreadPoolHealthIndicator(ThreadPoolExecutor threadPool) {
this.threadPool = threadPool;
}
@Override
public Health health() {
//补充信息
Map&lt;String, Integer&gt; detail = new HashMap&lt;&gt;();
//队列当前元素个数
detail.put(&quot;queue_size&quot;, threadPool.getQueue().size());
//队列剩余容量
detail.put(&quot;queue_remaining&quot;, threadPool.getQueue().remainingCapacity());
//如果还有剩余量则返回UP否则返回DOWN
if (threadPool.getQueue().remainingCapacity() &gt; 0) {
return Health.up().withDetails(detail).build();
} else {
return Health.down().withDetails(detail).build();
}
}
}
```
再定义一个CompositeHealthContributor来聚合两个ThreadPoolHealthIndicator的实例分别对应ThreadPoolProvider中定义的两个线程池
```
@Component
public class ThreadPoolsHealthContributor implements CompositeHealthContributor {
//保存所有的子HealthContributor
private Map&lt;String, HealthContributor&gt; contributors = new HashMap&lt;&gt;();
ThreadPoolsHealthContributor() {
//对应ThreadPoolProvider中定义的两个线程池
this.contributors.put(&quot;demoThreadPool&quot;, new ThreadPoolHealthIndicator(ThreadPoolProvider.getDemoThreadPool()));
this.contributors.put(&quot;ioThreadPool&quot;, new ThreadPoolHealthIndicator(ThreadPoolProvider.getIOThreadPool()));
}
@Override
public HealthContributor getContributor(String name) {
//根据name找到某一个HealthContributor
return contributors.get(name);
}
@Override
public Iterator&lt;NamedContributor&lt;HealthContributor&gt;&gt; iterator() {
//返回NamedContributor的迭代器NamedContributor也就是Contributor实例+一个命名
return contributors.entrySet().stream()
.map((entry) -&gt; NamedContributor.of(entry.getKey(), entry.getValue())).iterator();
}
}
```
程序启动后可以看到health接口展现了线程池和外部服务userService的健康状态以及一些具体信息
<img src="https://static001.geekbang.org/resource/image/d2/dc/d2721794203dcabf411e15143e342cdc.png" alt="">
我们看到一个demoThreadPool为DOWN导致父threadPools为DOWN进一步导致整个程序的status为DOWN
<img src="https://static001.geekbang.org/resource/image/bc/54/bc947b0c6d4a2a71987f16f16120eb54.png" alt="">
以上就是通过自定义HealthContributor和CompositeHealthContributor来实现监控检测触达程序内部诸如三方服务、线程池等关键组件是不是很方便呢
额外补充一下,[Spring Boot 2.3.0](https://spring.io/blog/2020/03/25/liveness-and-readiness-probes-with-spring-boot)增强了健康检测的功能细化了Liveness和Readiness两个端点便于Spring Boot应用程序和Kubernetes整合。
## 对外暴露应用内部重要组件的状态
除了可以把线程池的状态作为整个应用程序是否健康的依据外我们还可以通过Actuator的InfoContributor功能对外暴露程序内部重要组件的状态数据。这里我会用一个例子演示使用info的HTTP端点、JMX MBean这两种方式如何查看状态数据。
我们看一个具体案例实现一个ThreadPoolInfoContributor来展现线程池的信息。
```
@Component
public class ThreadPoolInfoContributor implements InfoContributor {
private static Map threadPoolInfo(ThreadPoolExecutor threadPool) {
Map&lt;String, Object&gt; info = new HashMap&lt;&gt;();
info.put(&quot;poolSize&quot;, threadPool.getPoolSize());//当前池大小
info.put(&quot;corePoolSize&quot;, threadPool.getCorePoolSize());//设置的核心池大小
info.put(&quot;largestPoolSize&quot;, threadPool.getLargestPoolSize());//最大达到过的池大小
info.put(&quot;maximumPoolSize&quot;, threadPool.getMaximumPoolSize());//设置的最大池大小
info.put(&quot;completedTaskCount&quot;, threadPool.getCompletedTaskCount());//总完成任务数
return info;
}
@Override
public void contribute(Info.Builder builder) {
builder.withDetail(&quot;demoThreadPool&quot;, threadPoolInfo(ThreadPoolProvider.getDemoThreadPool()));
builder.withDetail(&quot;ioThreadPool&quot;, threadPoolInfo(ThreadPoolProvider.getIOThreadPool()));
}
}
```
访问/admin/info接口可以看到这些数据
<img src="https://static001.geekbang.org/resource/image/7e/41/7ed02ed4d047293fe1287e82a6bf8041.png" alt="">
此外如果设置开启JMX的话
```
spring.jmx.enabled=true
```
可以通过jconsole工具在org.springframework.boot.Endpoint中找到Info这个MBean然后执行info操作可以看到我们刚才自定义的InfoContributor输出的有关两个线程池的信息
<img src="https://static001.geekbang.org/resource/image/f7/14/f7c4dd062934be5ca9a5628e7c5d0714.png" alt="">
这里我再额外补充一点。对于查看和操作MBean除了使用jconsole之外你可以使用jolokia把JMX转换为HTTP协议引入依赖
```
&lt;dependency&gt;
&lt;groupId&gt;org.jolokia&lt;/groupId&gt;
&lt;artifactId&gt;jolokia-core&lt;/artifactId&gt;
&lt;/dependency&gt;
```
然后你就可以通过jolokia来执行org.springframework.boot:type=Endpoint,name=Info这个MBean的info操作
<img src="https://static001.geekbang.org/resource/image/f7/7f/f7a128cb3efc652b63b773fdceb65f7f.png" alt="">
## 指标Metrics是快速定位问题的“金钥匙”
指标是指一组和时间关联的、衡量某个维度能力的量化数值。通过收集指标并展现为曲线图、饼图等图表,可以帮助我们快速定位、分析问题。
我们通过一个实际的案例,来看看如何通过图表快速定位问题。
有一个外卖订单的下单和配送流程如下图所示。OrderController进行下单操作下单操作前先判断参数如果参数正确调用另一个服务查询商户状态如果商户在营业的话继续下单下单成功后发一条消息到RabbitMQ进行异步配送流程然后另一个DeliverOrderHandler监听这条消息进行配送操作。
<img src="https://static001.geekbang.org/resource/image/d4/51/d45e1e97ce1f7881a5930e5eb6648351.png" alt="">
对于这样一个涉及同步调用和异步调用的业务流程,如果用户反馈下单失败,那我们如何才能快速知道是哪个环节出了问题呢?
这时,指标体系就可以发挥作用了。我们可以分别为下单和配送这两个重要操作,建立一些指标进行监控。
对于下单操作可以建立4个指标
- 下单总数量指标,监控整个系统当前累计的下单量;
- 下单请求指标,对于每次收到下单请求,在处理之前+1
- 下单成功指标,每次下单成功完成+1
- 下单失败指标,下单操作处理出现异常+1并且把异常原因附加到指标上。
对于配送操作也是建立类似的4个指标。我们可以使用Micrometer框架实现指标的收集它也是Spring Boot Actuator选用的指标框架。它实现了各种指标的抽象常用的有三种
- **gauge**红色它反映的是指标当前的值是多少就是多少不能累计比如本例中的下单总数量指标又比如游戏的在线人数、JVM当前线程数都可以认为是gauge。
- **counter**绿色每次调用一次方法值增加1是可以累计的比如本例中的下单请求指标。举一个例子如果5秒内我们调用了10次方法Micrometer也是每隔5秒把指标发送给后端存储系统一次那么它可以只发送一次值其值为10。
- **timer**蓝色类似counter只不过除了记录次数还记录耗时比如本例中的下单成功和下单失败两个指标。
所有的指标还可以附加一些tags标签作为补充数据。比如当操作执行失败的时候我们就会附加一个reason标签到指标上。
Micrometer除了抽象了指标外还抽象了存储。你可以把Micrometer理解为类似SLF4J这样的框架只不过后者针对日志抽象而Micrometer是针对指标进行抽象。Micrometer通过引入各种registry可以实现无缝对接各种监控系统或时间序列数据库。
在这个案例中我们引入了micrometer-registry-influx依赖目的是引入Micrometer的核心依赖以及通过Micrometer对于[InfluxDB](https://www.influxdata.com/products/influxdb-overview/)InfluxDB是一个时间序列数据库其专长是存储指标数据的绑定以实现指标数据可以保存到InfluxDB
```
&lt;dependency&gt;
&lt;groupId&gt;io.micrometer&lt;/groupId&gt;
&lt;artifactId&gt;micrometer-registry-influx&lt;/artifactId&gt;
&lt;/dependency&gt;
```
然后修改配置文件启用指标输出到InfluxDB的开关、配置InfluxDB的地址以及设置指标每秒在客户端聚合一次然后发送到InfluxDB
```
management.metrics.export.influx.enabled=true
management.metrics.export.influx.uri=http://localhost:8086
management.metrics.export.influx.step=1S
```
接下来,我们在业务逻辑中增加相关的代码来记录指标。
下面是OrderController的实现代码中有详细注释我就不一一说明了。你需要注意观察如何通过Micrometer框架来实现下单总数量、下单请求、下单成功和下单失败这四个指标分别对应代码的第17、25、43、47行
```
//下单操作,以及商户服务的接口
@Slf4j
@RestController
@RequestMapping(&quot;order&quot;)
public class OrderController {
//总订单创建数量
private AtomicLong createOrderCounter = new AtomicLong();
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RestTemplate restTemplate;
@PostConstruct
public void init() {
//注册createOrder.received指标gauge指标只需要像这样初始化一次直接关联到AtomicLong引用即可
Metrics.gauge(&quot;createOrder.totalSuccess&quot;, createOrderCounter);
}
//下单接口提供用户ID和商户ID作为入参
@GetMapping(&quot;createOrder&quot;)
public void createOrder(@RequestParam(&quot;userId&quot;) long userId, @RequestParam(&quot;merchantId&quot;) long merchantId) {
//记录一次createOrder.received指标这是一个counter指标表示收到下单请求
Metrics.counter(&quot;createOrder.received&quot;).increment();
Instant begin = Instant.now();
try {
TimeUnit.MILLISECONDS.sleep(200);
//模拟无效用户的情况ID&lt;10为无效用户
if (userId &lt; 10)
throw new RuntimeException(&quot;invalid user&quot;);
//查询商户服务
Boolean merchantStatus = restTemplate.getForObject(&quot;http://localhost:45678/order/getMerchantStatus?merchantId=&quot; + merchantId, Boolean.class);
if (merchantStatus == null || !merchantStatus)
throw new RuntimeException(&quot;closed merchant&quot;);
Order order = new Order();
order.setId(createOrderCounter.incrementAndGet()); //gauge指标可以得到自动更新
order.setUserId(userId);
order.setMerchantId(merchantId);
//发送MQ消息
rabbitTemplate.convertAndSend(Consts.EXCHANGE, Consts.ROUTING_KEY, order);
//记录一次createOrder.success指标这是一个timer指标表示下单成功同时提供耗时
Metrics.timer(&quot;createOrder.success&quot;).record(Duration.between(begin, Instant.now()));
} catch (Exception ex) {
log.error(&quot;creareOrder userId {} failed&quot;, userId, ex);
//记录一次createOrder.failed指标这是一个timer指标表示下单失败同时提供耗时并且以tag记录失败原因
Metrics.timer(&quot;createOrder.failed&quot;, &quot;reason&quot;, ex.getMessage()).record(Duration.between(begin, Instant.now()));
}
}
//商户查询接口
@GetMapping(&quot;getMerchantStatus&quot;)
public boolean getMerchantStatus(@RequestParam(&quot;merchantId&quot;) long merchantId) throws InterruptedException {
//只有商户ID为2的商户才是营业的
TimeUnit.MILLISECONDS.sleep(200);
return merchantId == 2;
}
}
```
当用户ID&lt;10的时候我们模拟用户数据无效的情况当商户ID不为2的时候我们模拟商户不营业的情况。
接下来是DeliverOrderHandler配送服务的实现。
其中deliverOrder方法监听OrderController发出的MQ消息模拟配送。如下代码所示第17、25、32和36行代码实现了配送相关四个指标的记录
```
//配送服务消息处理程序
@RestController
@Slf4j
@RequestMapping(&quot;deliver&quot;)
public class DeliverOrderHandler {
//配送服务运行状态
private volatile boolean deliverStatus = true;
private AtomicLong deliverCounter = new AtomicLong();
//通过一个外部接口来改变配送状态模拟配送服务停工
@PostMapping(&quot;status&quot;)
public void status(@RequestParam(&quot;status&quot;) boolean status) {
deliverStatus = status;
}
@PostConstruct
public void init() {
//同样注册一个gauge指标deliverOrder.totalSuccess代表总的配送单量只需注册一次即可
Metrics.gauge(&quot;deliverOrder.totalSuccess&quot;, deliverCounter);
}
//监听MQ消息
@RabbitListener(queues = Consts.QUEUE_NAME)
public void deliverOrder(Order order) {
Instant begin = Instant.now();
//对deliverOrder.received进行递增代表收到一次订单消息counter类型
Metrics.counter(&quot;deliverOrder.received&quot;).increment();
try {
if (!deliverStatus)
throw new RuntimeException(&quot;deliver outofservice&quot;);
TimeUnit.MILLISECONDS.sleep(500);
deliverCounter.incrementAndGet();
//配送成功指标deliverOrder.successtimer类型
Metrics.timer(&quot;deliverOrder.success&quot;).record(Duration.between(begin, Instant.now()));
} catch (Exception ex) {
log.error(&quot;deliver Order {} failed&quot;, order, ex);
//配送失败指标deliverOrder.failed同样附加了失败原因作为tagstimer类型
Metrics.timer(&quot;deliverOrder.failed&quot;, &quot;reason&quot;, ex.getMessage()).record(Duration.between(begin, Instant.now()));
}
}
}
```
同时我们模拟了一个配送服务整体状态的开关调用status接口可以修改其状态。至此我们完成了场景准备接下来开始配置指标监控。
首先,我们来[安装Grafana](https://grafana.com/docs/grafana/latest/installation/)。然后进入Grafana配置一个InfluxDB数据源
<img src="https://static001.geekbang.org/resource/image/e7/96/e74a6f9ac6840974413486239eb4b796.jpg" alt="">
配置好数据源之后,就可以添加一个监控面板,然后在面板中添加各种监控图表。比如,我们在一个下单次数图表中添加了下单收到、成功和失败三个指标。
<img src="https://static001.geekbang.org/resource/image/b9/25/b942d8bad647e10417acbc96ed289b25.jpg" alt="">
关于这张图中的配置:
- 红色框数据源配置,选择刚才配置的数据源。
- 蓝色框FROM配置选择我们的指标名。
- 绿色框SELECT配置选择我们要查询的指标字段也可以应用一些聚合函数。在这里我们取count字段的值然后使用sum函数进行求和。
- 紫色框GROUP BY配置我们配置了按1分钟时间粒度和reason字段进行分组这样指标的Y轴代表QPM每分钟请求数且每种失败的情况都会绘制单独的曲线。
- 黄色框ALIAS BY配置中设置了每一个指标的别名在别名中引用了reason这个tag。
使用Grafana配置InfluxDB指标的详细方式你可以参考[这里](https://grafana.com/docs/grafana/latest/features/datasources/influxdb/)。其中的FROM、SELECT、GROUP BY的含义和SQL类似理解起来应该不困难。
类似地, 我们配置出一个完整的业务监控面板包含之前实现的8个指标
- 配置2个Gauge图表分别呈现总订单完成次数、总配送完成次数。
- 配置4个Graph图表分别呈现下单操作的次数和性能以及配送操作的次数和性能。
下面我们进入实战使用wrk针对四种情况进行压测然后通过曲线来分析定位问题。
**第一种情况是使用合法的用户ID和营业的商户ID运行一段时间**
```
wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=20\&amp;merchantId\=2
```
**从监控面板可以一目了然地看到整个系统的运作情况。**可以看到目前系统运行良好不管是下单还是配送操作都是成功的且下单操作平均处理时间400ms、配送操作则是在500ms左右符合预期注意下单次数曲线中的绿色和黄色两条曲线其实是重叠在一起的表示所有下单都成功了
<img src="https://static001.geekbang.org/resource/image/11/83/117071b8d4f339eceaf50c87b6e69083.png" alt="">
**第二种情况是模拟无效用户ID运行一段时间**
```
wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=2\&amp;merchantId\=2
```
使用无效用户下单,显然会导致下单全部失败。接下来,我们就看看从监控图中是否能看到这个现象。
- 绿色框可以看到下单现在出现了invalid user这条蓝色的曲线并和绿色收到下单请求的曲线是吻合的表示所有下单都失败了原因是无效用户错误说明源头并没有问题。
- 红色框可以看到虽然下单都是失败的但是下单操作时间从400ms减少为200ms了说明下单失败之前也消耗了200ms和代码符合。而因为下单失败操作的响应时间减半了反而导致吞吐翻倍了。
- 观察两个配送监控可以发现配送曲线出现掉0现象是因为下单失败导致的下单失败MQ消息压根就不会发出。再注意下蓝色那条线可以看到配送曲线掉0延后于下单成功曲线的掉0原因是配送走的是异步流程虽然从某个时刻开始下单全部失败了但是MQ队列中还有一些之前未处理的消息。
<img src="https://static001.geekbang.org/resource/image/53/5b/536ce4dad0e8bc00aa6d9ad4ff285b5b.jpg" alt="">
**第三种情况是,尝试一下因为商户不营业导致的下单失败**
```
wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=20\&amp;merchantId\=1
```
我把变化的地方圈了出来,你可以自己尝试分析一下:
<img src="https://static001.geekbang.org/resource/image/4c/d4/4cf8d97266f5063550e5db57e61c73d4.jpg" alt="">
**第四种情况是,配送停止**。我们通过curl调用接口来设置配送停止开关
```
curl -X POST 'http://localhost:45678/deliver/status?status=false'
```
从监控可以看到从开关关闭那刻开始所有的配送消息全部处理失败了原因是deliver outofservice配送操作性能从500ms左右到了0ms说明配送失败是一个本地快速失败并不是因为服务超时等导致的失败。而且虽然配送失败但下单操作都是正常的
<img src="https://static001.geekbang.org/resource/image/c4/bc/c49bfce8682d382a04bd9dd8182534bc.jpg" alt="">
最后希望说的是除了手动添加业务监控指标外Micrometer框架还帮我们自动做了很多有关JVM内部各种数据的指标。进入InfluxDB命令行客户端你可以看到下面的这些表指标其中前8个是我们自己建的业务指标后面都是框架帮我们建的JVM、各种组件状态的指标
```
&gt; USE mydb
Using database mydb
&gt; SHOW MEASUREMENTS
name: measurements
name
----
createOrder_failed
createOrder_received
createOrder_success
createOrder_totalSuccess
deliverOrder_failed
deliverOrder_received
deliverOrder_success
deliverOrder_totalSuccess
hikaricp_connections
hikaricp_connections_acquire
hikaricp_connections_active
hikaricp_connections_creation
hikaricp_connections_idle
hikaricp_connections_max
hikaricp_connections_min
hikaricp_connections_pending
hikaricp_connections_timeout
hikaricp_connections_usage
http_server_requests
jdbc_connections_max
jdbc_connections_min
jvm_buffer_count
jvm_buffer_memory_used
jvm_buffer_total_capacity
jvm_classes_loaded
jvm_classes_unloaded
jvm_gc_live_data_size
jvm_gc_max_data_size
jvm_gc_memory_allocated
jvm_gc_memory_promoted
jvm_gc_pause
jvm_memory_committed
jvm_memory_max
jvm_memory_used
jvm_threads_daemon
jvm_threads_live
jvm_threads_peak
jvm_threads_states
logback_events
process_cpu_usage
process_files_max
process_files_open
process_start_time
process_uptime
rabbitmq_acknowledged
rabbitmq_acknowledged_published
rabbitmq_channels
rabbitmq_connections
rabbitmq_consumed
rabbitmq_failed_to_publish
rabbitmq_not_acknowledged_published
rabbitmq_published
rabbitmq_rejected
rabbitmq_unrouted_published
spring_rabbitmq_listener
system_cpu_count
system_cpu_usage
system_load_average_1m
tomcat_sessions_active_current
tomcat_sessions_active_max
tomcat_sessions_alive_max
tomcat_sessions_created
tomcat_sessions_expired
tomcat_sessions_rejected
```
我们可以按照自己的需求选取其中的一些指标在Grafana中配置应用监控面板
<img src="https://static001.geekbang.org/resource/image/13/e9/1378d9c6a66ea733cf08200d7f4b65e9.png" alt="">
看到这里,通过监控图表来定位问题,是不是比日志方便了很多呢?
## 重点回顾
今天我和你介绍了如何使用Spring Boot Actuaor实现生产就绪的几个关键点包括健康检测、暴露应用信息和指标监控。
所谓磨刀不误砍柴工健康检测可以帮我们实现负载均衡的联动应用信息以及Actuaor提供的各种端点可以帮我们查看应用内部情况甚至对应用的一些参数进行调整而指标监控则有助于我们整体观察应用运行情况帮助我们快速发现和定位问题。
其实完整的应用监控体系一般由三个方面构成包括日志Logging、指标Metrics和追踪Tracing。其中日志和指标我相信你应该已经比较清楚了。追踪一般不涉及开发工作就没有展开阐述我和你简单介绍一下。
追踪也叫做全链路追踪,比较有代表性的开源系统是[SkyWalking](https://skywalking.apache.org/)和[Pinpoint](https://github.com/naver/pinpoint)。一般而言接入此类系统无需额外开发使用其提供的javaagent来启动Java程序就可以通过动态修改字节码实现各种组件的改写以加入追踪代码类似AOP
全链路追踪的原理是:
1. 请求进入第一个组件时先生成一个TraceID作为整个调用链Trace的唯一标识
1. 对于每次操作都记录耗时和相关信息形成一个Span挂载到调用链上Span和Span之间同样可以形成树状关联出现远程调用、跨系统调用的时候把TraceID进行透传比如HTTP调用通过请求透传MQ消息则通过消息透传
1. 把这些数据汇总提交到数据库中通过一个UI界面查询整个树状调用链。
同时我们一般会把TraceID记录到日志中方便实现日志和追踪的关联。
我用一张图对比了日志、指标和追踪的区别和特点:
<img src="https://static001.geekbang.org/resource/image/85/4c/85cabd7ecb4c6a669ff2e8930a369c4c.jpg" alt="">
在我看来,完善的监控体系三者缺一不可,它们还可以相互配合,比如通过指标发现性能问题,通过追踪定位性能问题所在的应用和操作,最后通过日志定位出具体请求的明细参数。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. Spring Boot Actuator提供了大量内置端点你觉得端点和自定义一个@RestController有什么区别呢?你能否根据[官方文档](https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html#production-ready-endpoints-custom),开发一个自定义端点呢?
1. 在介绍指标Metrics时我们看到InfluxDB中保存了由Micrometer框架自动帮我们收集的一些应用指标。你能否参考源码中两个Grafana配置的JSON文件把这些指标在Grafana中配置出一个完整的应用监控面板呢
应用投产之前,你还会做哪些生产就绪方面的工作呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,612 @@
<audio id="audio" title="25 | 异步处理好用,但非常容易用错" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/24/0141dac641c535a30a2bdafadcca2924.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊好用但容易出错的异步处理。
异步处理是互联网应用不可或缺的一种架构模式,大多数业务项目都是由同步处理、异步处理和定时任务处理三种模式相辅相成实现的。
区别于同步处理,异步处理无需同步等待流程处理完毕,因此适用场景主要包括:
- 服务于主流程的分支流程。比如,在注册流程中,把数据写入数据库的操作是主流程,但注册后给用户发优惠券或欢迎短信的操作是分支流程,时效性不那么强,可以进行异步处理。
- 用户不需要实时看到结果的流程。比如,下单后的配货、送货流程完全可以进行异步处理,每个阶段处理完成后,再给用户发推送或短信让用户知晓即可。
同时异步处理因为可以有MQ中间件的介入用于任务的缓冲的分发所以相比于同步处理在应对流量洪峰、实现模块解耦和消息广播方面有功能优势。
不过异步处理虽然好用但在实现的时候却有三个最容易犯的错分别是异步处理流程的可靠性问题、消息发送模式的区分问题以及大量死信消息堵塞队列的问题。今天我就用三个代码案例结合目前常用的MQ系统RabbitMQ来和你具体聊聊。
今天这一讲的演示我都会使用Spring AMQP来操作RabbitMQ所以你需要先引入amqp依赖
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-amqp&lt;/artifactId&gt;
&lt;/dependency&gt;
```
## 异步处理需要消息补偿闭环
使用类似RabbitMQ、RocketMQ等MQ系统来做消息队列实现异步处理虽然说消息可以落地到磁盘保存即使MQ出现问题消息数据也不会丢失但是异步流程在消息发送、传输、处理等环节都可能发生消息丢失。此外任何MQ中间件都无法确保100%可用,需要考虑不可用时异步流程如何继续进行。
因此,**对于异步处理流程,必须考虑补偿或者说建立主备双活流程**。
我们来看一个用户注册后异步发送欢迎消息的场景。用户注册落数据库的流程为同步流程,会员服务收到消息后发送欢迎消息的流程为异步流程。
<img src="https://static001.geekbang.org/resource/image/62/93/629d9f0557cd7f06ac9ee2e871524893.png" alt="">
我们来分析一下:
- 蓝色的线使用MQ进行的异步处理我们称作主线可能存在消息丢失的情况虚线代表异步调用
- 绿色的线使用补偿Job定期进行消息补偿我们称作备线用来补偿主线丢失的消息
- 考虑到极端的MQ中间件失效的情况我们要求备线的处理吞吐能力达到主线的能力水平。
我们来看一下相关的实现代码。
首先定义UserController用于注册+发送异步消息。对于注册方法我们一次性注册10个用户用户注册消息不能发送出去的概率为50%。
```
@RestController
@Slf4j
@RequestMapping(&quot;user&quot;)
public class UserController {
@Autowired
private UserService userService;
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping(&quot;register&quot;)
public void register() {
//模拟10个用户注册
IntStream.rangeClosed(1, 10).forEach(i -&gt; {
//落库
User user = userService.register();
//模拟50%的消息可能发送失败
if (ThreadLocalRandom.current().nextInt(10) % 2 == 0) {
//通过RabbitMQ发送消息
rabbitTemplate.convertAndSend(RabbitConfiguration.EXCHANGE, RabbitConfiguration.ROUTING_KEY, user);
log.info(&quot;sent mq user {}&quot;, user.getId());
}
});
}
}
```
然后定义MemberService类用于模拟会员服务。会员服务监听用户注册成功的消息并发送欢迎短信。我们使用ConcurrentHashMap来存放那些发过短信的用户ID实现幂等避免相同的用户进行补偿时重复发送短信
```
@Component
@Slf4j
public class MemberService {
//发送欢迎消息的状态
private Map&lt;Long, Boolean&gt; welcomeStatus = new ConcurrentHashMap&lt;&gt;();
//监听用户注册成功的消息,发送欢迎消息
@RabbitListener(queues = RabbitConfiguration.QUEUE)
public void listen(User user) {
log.info(&quot;receive mq user {}&quot;, user.getId());
welcome(user);
}
//发送欢迎消息
public void welcome(User user) {
//去重操作
if (welcomeStatus.putIfAbsent(user.getId(), true) == null) {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
}
log.info(&quot;memberService: welcome new user {}&quot;, user.getId());
}
}
}
```
对于MQ消费程序处理逻辑务必考虑去重支持幂等原因有几个
- MQ消息可能会因为中间件本身配置错误、稳定性等原因出现重复。
- 自动补偿重复比如本例同一条消息可能既走MQ也走补偿肯定会出现重复而且考虑到高内聚补偿Job本身不会做去重处理。
- 人工补偿重复。出现消息堆积时异步处理流程必然会延迟。如果我们提供了通过后台进行补偿的功能那么在处理遇到延迟的时候很可能会先进行人工补偿过了一段时间后处理程序又收到消息了重复处理。我之前就遇到过一次由MQ故障引发的事故MQ中堆积了几十万条发放资金的消息导致业务无法及时处理运营以为程序出错了就先通过后台进行了人工处理结果MQ系统恢复后消息又被重复处理了一次造成大量资金重复发放。
接下来定义补偿Job也就是备线操作。
我们在CompensationJob中定义一个@Scheduled定时任务5秒做一次补偿操作因为Job并不知道哪些用户注册的消息可能丢失所以是全量补偿补偿逻辑是每5秒补偿一次按顺序一次补偿5个用户下一次补偿操作从上一次补偿的最后一个用户ID开始对于补偿任务我们提交到线程池进行“异步”处理提高处理能力。
```
@Component
@Slf4j
public class CompensationJob {
//补偿Job异步处理线程池
private static ThreadPoolExecutor compensationThreadPool = new ThreadPoolExecutor(
10, 10,
1, TimeUnit.HOURS,
new ArrayBlockingQueue&lt;&gt;(1000),
new ThreadFactoryBuilder().setNameFormat(&quot;compensation-threadpool-%d&quot;).get());
@Autowired
private UserService userService;
@Autowired
private MemberService memberService;
//目前补偿到哪个用户ID
private long offset = 0;
//10秒后开始补偿5秒补偿一次
@Scheduled(initialDelay = 10_000, fixedRate = 5_000)
public void compensationJob() {
log.info(&quot;开始从用户ID {} 补偿&quot;, offset);
//获取从offset开始的用户
userService.getUsersAfterIdWithLimit(offset, 5).forEach(user -&gt; {
compensationThreadPool.execute(() -&gt; memberService.welcome(user));
offset = user.getId();
});
}
}
```
为了实现高内聚主线和备线处理消息最好使用同一个方法。比如本例中MemberService监听到MQ消息和CompensationJob补偿调用的都是welcome方法。
此外值得一说的是Demo中的补偿逻辑比较简单生产级的代码应该在以下几个方面进行加强
- 考虑配置补偿的频次、每次处理数量,以及补偿线程池大小等参数为合适的值,以满足补偿的吞吐量。
- 考虑备线补偿数据进行适当延迟。比如对注册时间在30秒之前的用户再进行补偿以方便和主线MQ实时流程错开避免冲突。
- 诸如当前补偿到哪个用户的offset数据需要落地数据库。
- 补偿Job本身需要高可用可以使用类似XXLJob或ElasticJob等任务系统。
运行程序执行注册方法注册10个用户输出如下
```
[17:01:16.570] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 1
[17:01:16.571] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 5
[17:01:16.572] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 7
[17:01:16.573] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 8
[17:01:16.594] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 1
[17:01:18.597] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 1
[17:01:18.601] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 5
[17:01:20.603] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 5
[17:01:20.604] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 7
[17:01:22.605] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 7
[17:01:22.606] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 8
[17:01:24.611] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 8
[17:01:25.498] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 0 补偿
[17:01:27.510] [compensation-threadpool-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 2
[17:01:27.510] [compensation-threadpool-3] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 4
[17:01:27.511] [compensation-threadpool-2] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 3
[17:01:30.496] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 5 补偿
[17:01:32.500] [compensation-threadpool-6] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 6
[17:01:32.500] [compensation-threadpool-9] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 9
[17:01:35.496] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 9 补偿
[17:01:37.501] [compensation-threadpool-0] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 10
[17:01:40.495] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 10 补偿
```
可以看到:
- 总共10个用户MQ发送成功的用户有四个分别是用户1、5、7、8。
- 补偿任务第一次运行补偿了用户2、3、4第二次运行补偿了用户6、9第三次运行补充了用户10。
最后提一下针对消息的补偿闭环处理的最高标准是能够达到补偿全量数据的吞吐量。也就是说如果补偿备线足够完善即使直接把MQ停机虽然会略微影响处理的及时性但至少确保流程都能正常执行。
## 注意消息模式是广播还是工作队列
在今天这一讲的一开始,我们提到异步处理的一个重要优势,是实现消息广播。
消息广播,和我们平时说的“广播”意思差不多,就是希望同一条消息,不同消费者都能分别消费;而队列模式,就是不同消费者共享消费同一个队列的数据,相同消息只能被某一个消费者消费一次。
比如同一个用户的注册消息会员服务需要监听以发送欢迎短信营销服务同样需要监听以发送新用户小礼物。但是会员服务、营销服务都可能有多个实例我们期望的是同一个用户的消息可以同时广播给不同的服务广播模式但对于同一个服务的不同实例比如会员服务1和会员服务2不管哪个实例来处理处理一次即可工作队列模式
<img src="https://static001.geekbang.org/resource/image/79/14/79994116247045ff90652254770a6d14.png" alt="">
在实现代码的时候我们务必确认MQ系统的机制确保消息的路由按照我们的期望。
对于类似RocketMQ这样的MQ来说实现类似功能比较简单直白如果消费者属于一个组那么消息只会由同一个组的一个消费者来消费如果消费者属于不同组那么每个组都能消费一遍消息。
而对于RabbitMQ来说消息路由的模式采用的是队列+交换器队列是消息的载体交换器决定了消息路由到队列的方式配置比较复杂容易出错。所以接下来我重点和你讲讲RabbitMQ的相关代码实现。
我们还是以上面的架构图为例来演示使用RabbitMQ实现广播模式和工作队列模式的坑。
**第一步,实现会员服务监听用户服务发出的新用户注册消息的那部分逻辑。**
如果我们启动两个会员服务,那么同一个用户的注册消息应该只能被其中一个实例消费。
我们分别实现RabbitMQ队列、交换器、绑定三件套。其中队列用的是匿名队列交换器用的是直接交换器DirectExchange交换器绑定到匿名队列的路由Key是空字符串。在收到消息之后我们会打印所在实例使用的端口
```
//为了代码简洁直观我们把消息发布者、消费者、以及MQ的配置代码都放在了一起
@Slf4j
@Configuration
@RestController
@RequestMapping(&quot;workqueuewrong&quot;)
public class WorkQueueWrong {
private static final String EXCHANGE = &quot;newuserExchange&quot;;
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping
public void sendMessage() {
rabbitTemplate.convertAndSend(EXCHANGE, &quot;&quot;, UUID.randomUUID().toString());
}
//使用匿名队列作为消息队列
@Bean
public Queue queue() {
return new AnonymousQueue();
}
//声明DirectExchange交换器绑定队列到交换器
@Bean
public Declarables declarables() {
DirectExchange exchange = new DirectExchange(EXCHANGE);
return new Declarables(queue(), exchange,
BindingBuilder.bind(queue()).to(exchange).with(&quot;&quot;));
}
//监听队列队列名称直接通过SpEL表达式引用Bean
@RabbitListener(queues = &quot;#{queue.name}&quot;)
public void memberService(String userName) {
log.info(&quot;memberService: welcome message sent to new user {} from {}&quot;, userName, System.getProperty(&quot;server.port&quot;));
}
}
```
使用12345和45678两个端口启动两个程序实例后调用sendMessage接口发送一条消息输出的日志显示**同一个会员服务两个实例都收到了消息**
<img src="https://static001.geekbang.org/resource/image/bd/5f/bd649f78f2f3a7c732b8883fd4d5255f.png" alt="">
<img src="https://static001.geekbang.org/resource/image/96/04/96278ba64ac411d5910d7ce8073c7304.png" alt="">
**出现这个问题的原因是我们没有理清楚RabbitMQ直接交换器和队列的绑定关系。**
如下图所示RabbitMQ的直接交换器根据routingKey对消息进行路由。由于我们的程序每次启动都会创建匿名随机命名的队列所以相当于每一个会员服务实例都对应独立的队列以空routingKey绑定到直接交换器。用户服务发出消息的时候也设置了routingKey为空所以直接交换器收到消息之后发现有两条队列匹配于是都转发了消息
<img src="https://static001.geekbang.org/resource/image/c6/f8/c685c1a07347b040ee5ba1b48ce00af8.png" alt="">
要修复这个问题其实很简单,对于会员服务不要使用匿名队列,而是使用同一个队列即可。把上面代码中的匿名队列替换为一个普通队列:
```
private static final String QUEUE = &quot;newuserQueue&quot;;
@Bean
public Queue queue() {
return new Queue(QUEUE);
}
```
测试发现,对于同一条消息来说,两个实例中只有一个实例可以收到,不同的消息按照轮询分发给不同的实例。现在,交换器和队列的关系是这样的:
<img src="https://static001.geekbang.org/resource/image/65/7b/65205002a2cdde62d55330263afd317b.png" alt="">
**第二步,进一步完整实现用户服务需要广播消息给会员服务和营销服务的逻辑。**
我们希望会员服务和营销服务都可以收到广播消息,但会员服务或营销服务中的每个实例只需要收到一次消息。
代码如下我们声明了一个队列和一个广播交换器FanoutExchange然后模拟两个用户服务和两个营销服务
```
@Slf4j
@Configuration
@RestController
@RequestMapping(&quot;fanoutwrong&quot;)
public class FanoutQueueWrong {
private static final String QUEUE = &quot;newuser&quot;;
private static final String EXCHANGE = &quot;newuser&quot;;
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping
public void sendMessage() {
rabbitTemplate.convertAndSend(EXCHANGE, &quot;&quot;, UUID.randomUUID().toString());
}
//声明FanoutExchange然后绑定到队列FanoutExchange绑定队列的时候不需要routingKey
@Bean
public Declarables declarables() {
Queue queue = new Queue(QUEUE);
FanoutExchange exchange = new FanoutExchange(EXCHANGE);
return new Declarables(queue, exchange,
BindingBuilder.bind(queue).to(exchange));
}
//会员服务实例1
@RabbitListener(queues = QUEUE)
public void memberService1(String userName) {
log.info(&quot;memberService1: welcome message sent to new user {}&quot;, userName);
}
//会员服务实例2
@RabbitListener(queues = QUEUE)
public void memberService2(String userName) {
log.info(&quot;memberService2: welcome message sent to new user {}&quot;, userName);
}
//营销服务实例1
@RabbitListener(queues = QUEUE)
public void promotionService1(String userName) {
log.info(&quot;promotionService1: gift sent to new user {}&quot;, userName);
}
//营销服务实例2
@RabbitListener(queues = QUEUE)
public void promotionService2(String userName) {
log.info(&quot;promotionService2: gift sent to new user {}&quot;, userName);
}
}
```
我们请求四次sendMessage接口注册四个用户。通过日志可以发现**一条用户注册的消息,要么被会员服务收到,要么被营销服务收到,显然这不是广播**。那我们使用的FanoutExchange看名字就应该是实现广播的交换器为什么根本没有起作用呢
<img src="https://static001.geekbang.org/resource/image/34/6d/34e2ea5e0f38ac029ff3d909d8b9606d.png" alt="">
其实广播交换器非常简单它会忽略routingKey广播消息到所有绑定的队列。在这个案例中两个会员服务和两个营销服务都绑定了同一个队列所以这四个服务只能收到一次消息
<img src="https://static001.geekbang.org/resource/image/20/cb/20adae38645d1cc169756fb4888211cb.png" alt="">
修改方式很简单,我们把队列进行拆分,会员和营销两组服务分别使用一条独立队列绑定到广播交换器即可:
```
@Slf4j
@Configuration
@RestController
@RequestMapping(&quot;fanoutright&quot;)
public class FanoutQueueRight {
private static final String MEMBER_QUEUE = &quot;newusermember&quot;;
private static final String PROMOTION_QUEUE = &quot;newuserpromotion&quot;;
private static final String EXCHANGE = &quot;newuser&quot;;
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping
public void sendMessage() {
rabbitTemplate.convertAndSend(EXCHANGE, &quot;&quot;, UUID.randomUUID().toString());
}
@Bean
public Declarables declarables() {
//会员服务队列
Queue memberQueue = new Queue(MEMBER_QUEUE);
//营销服务队列
Queue promotionQueue = new Queue(PROMOTION_QUEUE);
//广播交换器
FanoutExchange exchange = new FanoutExchange(EXCHANGE);
//两个队列绑定到同一个交换器
return new Declarables(memberQueue, promotionQueue, exchange,
BindingBuilder.bind(memberQueue).to(exchange),
BindingBuilder.bind(promotionQueue).to(exchange));
}
@RabbitListener(queues = MEMBER_QUEUE)
public void memberService1(String userName) {
log.info(&quot;memberService1: welcome message sent to new user {}&quot;, userName);
}
@RabbitListener(queues = MEMBER_QUEUE)
public void memberService2(String userName) {
log.info(&quot;memberService2: welcome message sent to new user {}&quot;, userName);
}
@RabbitListener(queues = PROMOTION_QUEUE)
public void promotionService1(String userName) {
log.info(&quot;promotionService1: gift sent to new user {}&quot;, userName);
}
@RabbitListener(queues = PROMOTION_QUEUE)
public void promotionService2(String userName) {
log.info(&quot;promotionService2: gift sent to new user {}&quot;, userName);
}
}
```
现在,交换器和队列的结构是这样的:
<img src="https://static001.geekbang.org/resource/image/9a/78/9a3b06605913aa17025854dfbe6a5778.png" alt="">
从日志输出可以验证对于每一条MQ消息会员服务和营销服务分别都会收到一次一条消息广播到两个服务的同时在每一个服务的两个实例中通过轮询接收
<img src="https://static001.geekbang.org/resource/image/29/63/2975386cec273f3ca54b42872d9f4b63.png" alt="">
所以说理解了RabbitMQ直接交换器、广播交换器的工作方式之后我们对消息的路由方式了解得很清晰了实现代码就不会出错。
对于异步流程来说,消息路由模式一旦配置出错,轻则可能导致消息的重复处理,重则可能导致重要的服务无法接收到消息,最终造成业务逻辑错误。
每个MQ中间件对消息的路由处理的配置各不相同我们一定要先了解原理再着手编码。
## 别让死信堵塞了消息队列
我们在介绍[线程池](https://time.geekbang.org/column/article/210337)的时候提到如果线程池的任务队列没有上限那么最终可能会导致OOM。使用消息队列处理异步流程的时候我们也同样要注意消息队列的任务堆积问题。对于突发流量引起的消息队列堆积问题并不大适当调整消费者的消费能力应该就可以解决。**但在很多时候,消息队列的堆积堵塞,是因为有大量始终无法处理的消息**。
比如用户服务在用户注册后发出一条消息会员服务监听到消息后给用户派发优惠券但因为用户并没有保存成功会员服务处理消息始终失败消息重新进入队列然后还是处理失败。这种在MQ中像幽灵一样回荡的同一条消息就是死信。
随着MQ被越来越多的死信填满消费者需要花费大量时间反复处理死信导致正常消息的消费受阻**最终MQ可能因为数据量过大而崩溃**。
我们来测试一下这个场景。首先,定义一个队列、一个直接交换器,然后把队列绑定到交换器:
```
@Bean
public Declarables declarables() {
//队列
Queue queue = new Queue(Consts.QUEUE);
//交换器
DirectExchange directExchange = new DirectExchange(Consts.EXCHANGE);
//快速声明一组对象,包含队列、交换器,以及队列到交换器的绑定
return new Declarables(queue, directExchange,
BindingBuilder.bind(queue).to(directExchange).with(Consts.ROUTING_KEY));
}
```
然后实现一个sendMessage方法来发送消息到MQ访问一次提交一条消息使用自增标识作为消息内容
```
//自增消息标识
AtomicLong atomicLong = new AtomicLong();
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping(&quot;sendMessage&quot;)
public void sendMessage() {
String msg = &quot;msg&quot; + atomicLong.incrementAndGet();
log.info(&quot;send message {}&quot;, msg);
//发送消息
rabbitTemplate.convertAndSend(Consts.EXCHANGE, msg);
}
```
收到消息后,直接抛出空指针异常,模拟处理出错的情况:
```
@RabbitListener(queues = Consts.QUEUE)
public void handler(String data) {
log.info(&quot;got message {}&quot;, data);
throw new NullPointerException(&quot;error&quot;);
}
```
调用sendMessage接口发送两条消息然后来到RabbitMQ管理台可以看到这两条消息始终在队列中不断被重新投递导致重新投递QPS达到了1063。
<img src="https://static001.geekbang.org/resource/image/11/54/1130fc65dee6acba4df08227baf4d554.jpg" alt="">
同时,在日志中可以看到大量异常信息:
```
[20:02:31.533] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [WARN ] [o.s.a.r.l.ConditionalRejectingErrorHandler:129 ] - Execution of Rabbit message listener failed.
org.springframework.amqp.rabbit.support.ListenerExecutionFailedException: Listener method 'public void org.geekbang.time.commonmistakes.asyncprocess.deadletter.MQListener.handler(java.lang.String)' threw exception
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:219)
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandlerAndProcessResult(MessagingMessageListenerAdapter.java:143)
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.onMessage(MessagingMessageListenerAdapter.java:132)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1569)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1488)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1476)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doExecuteListener(AbstractMessageListenerContainer.java:1467)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1411)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.doReceiveAndExecute(SimpleMessageListenerContainer.java:958)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.receiveAndExecute(SimpleMessageListenerContainer.java:908)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.access$1600(SimpleMessageListenerContainer.java:81)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.mainLoop(SimpleMessageListenerContainer.java:1279)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.run(SimpleMessageListenerContainer.java:1185)
at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.NullPointerException: error
at org.geekbang.time.commonmistakes.asyncprocess.deadletter.MQListener.handler(MQListener.java:14)
at sun.reflect.GeneratedMethodAccessor46.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.springframework.messaging.handler.invocation.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:171)
at org.springframework.messaging.handler.invocation.InvocableHandlerMethod.invoke(InvocableHandlerMethod.java:120)
at org.springframework.amqp.rabbit.listener.adapter.HandlerAdapter.invoke(HandlerAdapter.java:50)
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:211)
... 13 common frames omitted
```
解决死信无限重复进入队列最简单的方式是在程序处理出错的时候直接抛出AmqpRejectAndDontRequeueException异常避免消息重新进入队列
```
throw new AmqpRejectAndDontRequeueException(&quot;error&quot;);
```
但,我们更希望的逻辑是,对于同一条消息,能够先进行几次重试,解决因为网络问题导致的偶发消息处理失败,如果还是不行的话,再把消息投递到专门的一个死信队列。对于来自死信队列的数据,我们可能只是记录日志发送报警,即使出现异常也不会再重复投递。整个逻辑如下图所示:
<img src="https://static001.geekbang.org/resource/image/40/28/40f0cf14933178fd07690372199e8428.png" alt="">
针对这个问题Spring AMQP提供了非常方便的解决方案
- 首先,定义死信交换器和死信队列。其实,这些都是普通的交换器和队列,只不过被我们专门用于处理死信消息。
- 然后通过RetryInterceptorBuilder构建一个RetryOperationsInterceptor用于处理失败时候的重试。这里的策略是最多尝试5次重试4次并且采取指数退避重试首次重试延迟1秒第二次2秒以此类推最大延迟是10秒如果第4次重试还是失败则使用RepublishMessageRecoverer把消息重新投入一个“死信交换器”中。
- 最后,定义死信队列的处理程序。这个案例中,我们只是简单记录日志。
对应的实现代码如下:
```
//定义死信交换器和队列,并且进行绑定
@Bean
public Declarables declarablesForDead() {
Queue queue = new Queue(Consts.DEAD_QUEUE);
DirectExchange directExchange = new DirectExchange(Consts.DEAD_EXCHANGE);
return new Declarables(queue, directExchange,
BindingBuilder.bind(queue).to(directExchange).with(Consts.DEAD_ROUTING_KEY));
}
//定义重试操作拦截器
@Bean
public RetryOperationsInterceptor interceptor() {
return RetryInterceptorBuilder.stateless()
.maxAttempts(5) //最多尝试不是重试5次
.backOffOptions(1000, 2.0, 10000) //指数退避重试
.recoverer(new RepublishMessageRecoverer(rabbitTemplate, Consts.DEAD_EXCHANGE, Consts.DEAD_ROUTING_KEY)) //重新投递重试达到上限的消息
.build();
}
//通过定义SimpleRabbitListenerContainerFactory设置其adviceChain属性为之前定义的RetryOperationsInterceptor来启用重试拦截器
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(interceptor());
return factory;
}
//死信队列处理程序
@RabbitListener(queues = Consts.DEAD_QUEUE)
public void deadHandler(String data) {
log.error(&quot;got dead message {}&quot;, data);
}
```
执行程序,发送两条消息,日志如下:
```
[11:22:02.193] [http-nio-45688-exec-1] [INFO ] [o.g.t.c.a.d.DeadLetterController:24 ] - send message msg1
[11:22:02.219] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:02.614] [http-nio-45688-exec-2] [INFO ] [o.g.t.c.a.d.DeadLetterController:24 ] - send message msg2
[11:22:03.220] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:05.221] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:09.223] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:17.224] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:17.226] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [WARN ] [o.s.a.r.retry.RepublishMessageRecoverer:172 ] - Republishing failed message to exchange 'deadtest' with routing key deadtest
[11:22:17.227] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:17.229] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#1-1] [ERROR] [o.g.t.c.a.deadletter.MQListener:20 ] - got dead message msg1
[11:22:18.232] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:20.237] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:24.241] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:32.245] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:32.246] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [WARN ] [o.s.a.r.retry.RepublishMessageRecoverer:172 ] - Republishing failed message to exchange 'deadtest' with routing key deadtest
[11:22:32.250] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#1-1] [ERROR] [o.g.t.c.a.deadletter.MQListener:20 ] - got dead message msg2
```
可以看到:
- msg1的4次重试间隔分别是1秒、2秒、4秒、8秒再加上首次的失败所以最大尝试次数是5。
- 4次重试后RepublishMessageRecoverer把消息发往了死信交换器。
- 死信处理程序输出了got dead message日志。
这里需要尤其注意的一点是虽然我们几乎同时发送了两条消息但是msg2是在msg1的四次重试全部结束后才开始处理。原因是**默认情况下SimpleMessageListenerContainer只有一个消费线程**。可以通过增加消费线程来避免性能问题如下我们直接设置concurrentConsumers参数为10来增加到10个工作线程
```
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(interceptor());
factory.setConcurrentConsumers(10);
return factory;
}
```
当然我们也可以设置maxConcurrentConsumers参数来让SimpleMessageListenerContainer自己动态地调整消费者线程数。不过我们需要特别注意它的动态开启新线程的策略。你可以通过[官方文档](https://docs.spring.io/spring-amqp/docs/2.2.1.RELEASE/reference/html/#listener-concurrency),来了解这个策略。
## 重点回顾
在使用异步处理这种架构模式的时候我们一般都会使用MQ中间件配合实现异步流程需要重点考虑四个方面的问题。
第一,要考虑异步流程丢消息或处理中断的情况,异步流程需要有备线进行补偿。比如,我们今天介绍的全量补偿方式,即便异步流程彻底失效,通过补偿也能让业务继续进行。
第二,异步处理的时候需要考虑消息重复的可能性,处理逻辑需要实现幂等,防止重复处理。
第三微服务场景下不同服务多个实例监听消息的情况一般不同服务需要同时收到相同的消息而相同服务的多个实例只需要轮询接收消息。我们需要确认MQ的消息路由配置是否满足需求以避免消息重复或漏发问题。
第四要注意始终无法处理的死信消息可能会引发堵塞MQ的问题。一般在遇到消息处理失败的时候我们可以设置一定的重试策略。如果重试还是不行那可以把这个消息扔到专有的死信队列特别处理不要让死信影响到正常消息的处理。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在用户注册后发送消息到MQ然后会员服务监听消息进行异步处理的场景下有些时候我们会发现虽然用户服务先保存数据再发送MQ但会员服务收到消息后去查询数据库却发现数据库中还没有新用户的信息。你觉得这可能是什么问题呢又该如何解决呢
1. 除了使用Spring AMQP实现死信消息的重投递外RabbitMQ 2.8.0 后支持的死信交换器DLX也可以实现类似功能。你能尝试用DLX实现吗并比较下这两种处理机制
关于使用MQ进行异步处理流程你还遇到过其他问题吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,594 @@
<audio id="audio" title="26 | 数据存储NoSQL与RDBMS如何取长补短、相辅相成" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/74/99d6bb4b14d87138e87148d987122274.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊数据存储的常见错误。
近几年各种非关系型数据库也就是NoSQL发展迅猛在项目中也非常常见。其中不乏一些使用上的极端情况比如直接把关系型数据库RDBMS全部替换为NoSQL或是在不合适的场景下错误地使用NoSQL。
其实每种NoSQL的特点不同都有其要着重解决的某一方面的问题。因此我们在使用NoSQL的时候要尽量让它去处理擅长的场景否则不但发挥不出它的功能和优势还可能会导致性能问题。
NoSQL一般可以分为缓存数据库、时间序列数据库、全文搜索数据库、文档数据库、图数据库等。今天我会以缓存数据库Redis、时间序列数据库InfluxDB、全文搜索数据库ElasticSearch为例通过一些测试案例和你聊聊这些常见NoSQL的特点以及它们擅长和不擅长的地方。最后我也还会和你说说NoSQL如何与RDBMS相辅相成来构成一套可以应对高并发的复合数据库体系。
## 取长补短之 Redis vs MySQL
Redis是一款设计简洁的缓存数据库数据都保存在内存中所以读写单一Key的性能非常高。
我们来做一个简单测试分别填充10万条数据到Redis和MySQL中。MySQL中的name字段做了索引相当于Redis的Keydata字段为100字节的数据相当于Redis的Value
```
@SpringBootApplication
@Slf4j
public class CommonMistakesApplication {
//模拟10万条数据存到Redis和MySQL
public static final int ROWS = 100000;
public static final String PAYLOAD = IntStream.rangeClosed(1, 100).mapToObj(__ -&gt; &quot;a&quot;).collect(Collectors.joining(&quot;&quot;));
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StandardEnvironment standardEnvironment;
public static void main(String[] args) {
SpringApplication.run(CommonMistakesApplication.class, args);
}
@PostConstruct
public void init() {
//使用-Dspring.profiles.active=init启动程序进行初始化
if (Arrays.stream(standardEnvironment.getActiveProfiles()).anyMatch(s -&gt; s.equalsIgnoreCase(&quot;init&quot;))) {
initRedis();
initMySQL();
}
}
//填充数据到MySQL
private void initMySQL() {
//删除表
jdbcTemplate.execute(&quot;DROP TABLE IF EXISTS `r`;&quot;);
//新建表name字段做了索引
jdbcTemplate.execute(&quot;CREATE TABLE `r` (\n&quot; +
&quot; `id` bigint(20) NOT NULL AUTO_INCREMENT,\n&quot; +
&quot; `data` varchar(2000) NOT NULL,\n&quot; +
&quot; `name` varchar(20) NOT NULL,\n&quot; +
&quot; PRIMARY KEY (`id`),\n&quot; +
&quot; KEY `name` (`name`) USING BTREE\n&quot; +
&quot;) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;&quot;);
//批量插入数据
String sql = &quot;INSERT INTO `r` (`data`,`name`) VALUES (?,?)&quot;;
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
preparedStatement.setString(1, PAYLOAD);
preparedStatement.setString(2, &quot;item&quot; + i);
}
@Override
public int getBatchSize() {
return ROWS;
}
});
log.info(&quot;init mysql finished with count {}&quot;, jdbcTemplate.queryForObject(&quot;SELECT COUNT(*) FROM `r`&quot;, Long.class));
}
//填充数据到Redis
private void initRedis() {
IntStream.rangeClosed(1, ROWS).forEach(i -&gt; stringRedisTemplate.opsForValue().set(&quot;item&quot; + i, PAYLOAD));
log.info(&quot;init redis finished with count {}&quot;, stringRedisTemplate.keys(&quot;item*&quot;));
}
}
```
启动程序后,输出了如下日志,数据全部填充完毕:
```
[14:22:47.195] [main] [INFO ] [o.g.t.c.n.r.CommonMistakesApplication:80 ] - init redis finished with count 100000
[14:22:50.030] [main] [INFO ] [o.g.t.c.n.r.CommonMistakesApplication:74 ] - init mysql finished with count 100000
```
然后比较一下从MySQL和Redis随机读取单条数据的性能。“公平”起见像Redis那样我们使用MySQL时也根据Key来查Value也就是根据name字段来查data字段并且我们给name字段做了索引
```
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@GetMapping(&quot;redis&quot;)
public void redis() {
//使用随机的Key来查询Value结果应该等于PAYLOAD
Assert.assertTrue(stringRedisTemplate.opsForValue().get(&quot;item&quot; + (ThreadLocalRandom.current().nextInt(CommonMistakesApplication.ROWS) + 1)).equals(CommonMistakesApplication.PAYLOAD));
}
@GetMapping(&quot;mysql&quot;)
public void mysql() {
//根据随机name来查dataname字段有索引结果应该等于PAYLOAD
Assert.assertTrue(jdbcTemplate.queryForObject(&quot;SELECT data FROM `r` WHERE name=?&quot;, new Object[]{(&quot;item&quot; + (ThreadLocalRandom.current().nextInt(CommonMistakesApplication.ROWS) + 1))}, String.class)
.equals(CommonMistakesApplication.PAYLOAD));
}
```
在我的电脑上使用wrk 加10个线程50个并发连接做压测。可以看到MySQL 90%的请求需要61msQPS为1460**而Redis 90%的请求在5ms左右QPS达到了14008几乎是MySQL的十倍**
<img src="https://static001.geekbang.org/resource/image/2d/4e/2d289cc94097c2e62aa97a6602d0554e.png" alt="">
但Redis薄弱的地方是不擅长做Key的搜索。对MySQL我们可以使用LIKE操作前匹配走B+树索引实现快速搜索但对Redis我们使用Keys命令对Key的搜索其实相当于在MySQL里做全表扫描。
我写一段代码来对比一下性能:
```
@GetMapping(&quot;redis2&quot;)
public void redis2() {
Assert.assertTrue(stringRedisTemplate.keys(&quot;item71*&quot;).size() == 1111);
}
@GetMapping(&quot;mysql2&quot;)
public void mysql2() {
Assert.assertTrue(jdbcTemplate.queryForList(&quot;SELECT name FROM `r` WHERE name LIKE 'item71%'&quot;, String.class).size() == 1111);
}
```
可以看到在QPS方面**MySQL的QPS达到了Redis的157倍在延迟方面MySQL的延迟只有Redis的十分之一。**
<img src="https://static001.geekbang.org/resource/image/5d/e8/5de7a4a7bf27f8736b0ac09ba0dd1fe8.png" alt="">
Redis慢的原因有两个
- Redis的Keys命令是O(n)时间复杂度。如果数据库中Key的数量很多就会非常慢。
- Redis是单线程的对于慢的命令如果有并发串行执行就会非常耗时。
一般而言我们使用Redis都是针对某一个Key来使用而不能在业务代码中使用Keys命令从Redis中“搜索数据”因为这不是Redis的擅长。对于Key的搜索我们可以先通过关系型数据库进行然后再从Redis存取数据如果实在需要搜索Key可以使用SCAN命令。在生产环境中我们一般也会配置Redis禁用类似Keys这种比较危险的命令你可以[参考这里](https://redis.io/topics/security)。
总结一下,正如“[缓存设计](https://time.geekbang.org/column/article/231501)”一讲中提到的对于业务开发来说大多数业务场景下Redis是作为关系型数据库的辅助用于缓存的我们一般不会把它当作数据库独立使用。
此外值得一提的是Redis提供了丰富的数据结构Set、SortedSet、Hash、List并围绕这些数据结构提供了丰富的API。如果我们好好利用这个特点的话可以直接在Redis中完成一部分服务端计算避免“读取缓存-&gt;计算数据-&gt;保存缓存”三部曲中的读取和保存缓存的开销,进一步提高性能。
## 取长补短之 InfluxDB vs MySQL
InfluxDB是一款优秀的时序数据库。在“[生产就绪](https://time.geekbang.org/column/article/231568)”这一讲中我们就是使用InfluxDB来做的Metrics打点。时序数据库的优势在于处理指标数据的聚合并且读写效率非常高。
同样的我们使用一些测试来对比下InfluxDB和MySQL的性能。
在如下代码中我们分别填充了1000万条数据到MySQL和InfluxDB中。其中每条数据只有ID、时间戳、10000以内的随机值这3列信息对于MySQL我们把时间戳列做了索引
```
@SpringBootApplication
@Slf4j
public class CommonMistakesApplication {
public static void main(String[] args) {
SpringApplication.run(CommonMistakesApplication.class, args);
}
//测试数据量
public static final int ROWS = 10000000;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StandardEnvironment standardEnvironment;
@PostConstruct
public void init() {
//使用-Dspring.profiles.active=init启动程序进行初始化
if (Arrays.stream(standardEnvironment.getActiveProfiles()).anyMatch(s -&gt; s.equalsIgnoreCase(&quot;init&quot;))) {
initInfluxDB();
initMySQL();
}
}
//初始化MySQL
private void initMySQL() {
long begin = System.currentTimeMillis();
jdbcTemplate.execute(&quot;DROP TABLE IF EXISTS `m`;&quot;);
//只有ID、值和时间戳三列
jdbcTemplate.execute(&quot;CREATE TABLE `m` (\n&quot; +
&quot; `id` bigint(20) NOT NULL AUTO_INCREMENT,\n&quot; +
&quot; `value` bigint NOT NULL,\n&quot; +
&quot; `time` timestamp NOT NULL,\n&quot; +
&quot; PRIMARY KEY (`id`),\n&quot; +
&quot; KEY `time` (`time`) USING BTREE\n&quot; +
&quot;) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;&quot;);
String sql = &quot;INSERT INTO `m` (`value`,`time`) VALUES (?,?)&quot;;
//批量插入数据
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
preparedStatement.setLong(1, ThreadLocalRandom.current().nextInt(10000));
preparedStatement.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now().minusSeconds(5 * i)));
}
@Override
public int getBatchSize() {
return ROWS;
}
});
log.info(&quot;init mysql finished with count {} took {}ms&quot;, jdbcTemplate.queryForObject(&quot;SELECT COUNT(*) FROM `m`&quot;, Long.class), System.currentTimeMillis()-begin);
}
//初始化InfluxDB
private void initInfluxDB() {
long begin = System.currentTimeMillis();
OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient().newBuilder()
.connectTimeout(1, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS);
try (InfluxDB influxDB = InfluxDBFactory.connect(&quot;http://127.0.0.1:8086&quot;, &quot;root&quot;, &quot;root&quot;, okHttpClientBuilder)) {
String db = &quot;performance&quot;;
influxDB.query(new Query(&quot;DROP DATABASE &quot; + db));
influxDB.query(new Query(&quot;CREATE DATABASE &quot; + db));
//设置数据库
influxDB.setDatabase(db);
//批量插入10000条数据刷一次或1秒刷一次
influxDB.enableBatch(BatchOptions.DEFAULTS.actions(10000).flushDuration(1000));
IntStream.rangeClosed(1, ROWS).mapToObj(i -&gt; Point
.measurement(&quot;m&quot;)
.addField(&quot;value&quot;, ThreadLocalRandom.current().nextInt(10000))
.time(LocalDateTime.now().minusSeconds(5 * i).toInstant(ZoneOffset.UTC).toEpochMilli(), TimeUnit.MILLISECONDS).build())
.forEach(influxDB::write);
influxDB.flush();
log.info(&quot;init influxdb finished with count {} took {}ms&quot;, influxDB.query(new Query(&quot;SELECT COUNT(*) FROM m&quot;)).getResults().get(0).getSeries().get(0).getValues().get(0).get(1), System.currentTimeMillis()-begin);
}
}
}
```
启动后,程序输出了如下日志:
```
[16:08:25.062] [main] [INFO ] [o.g.t.c.n.i.CommonMistakesApplication:104 ] - init influxdb finished with count 1.0E7 took 54280ms
[16:11:50.462] [main] [INFO ] [o.g.t.c.n.i.CommonMistakesApplication:80 ] - init mysql finished with count 10000000 took 205394ms
```
InfluxDB批量插入1000万条数据仅用了54秒相当于每秒插入18万条数据速度相当快MySQL的批量插入速度也挺快达到了每秒4.8万。
接下来,我们测试一下。
对这1000万数据进行一个统计查询最近60天的数据按照1小时的时间粒度聚合统计value列的最大值、最小值和平均值并将统计结果绘制成曲线图
```
@Autowired
private JdbcTemplate jdbcTemplate;
@GetMapping(&quot;mysql&quot;)
public void mysql() {
long begin = System.currentTimeMillis();
//使用SQL从MySQL查询按照小时分组
Object result = jdbcTemplate.queryForList(&quot;SELECT date_format(time,'%Y%m%d%H'),max(value),min(value),avg(value) FROM m WHERE time&gt;now()- INTERVAL 60 DAY GROUP BY date_format(time,'%Y%m%d%H')&quot;);
log.info(&quot;took {} ms result {}&quot;, System.currentTimeMillis() - begin, result);
}
@GetMapping(&quot;influxdb&quot;)
public void influxdb() {
long begin = System.currentTimeMillis();
try (InfluxDB influxDB = InfluxDBFactory.connect(&quot;http://127.0.0.1:8086&quot;, &quot;root&quot;, &quot;root&quot;)) {
//切换数据库
influxDB.setDatabase(&quot;performance&quot;);
//InfluxDB的查询语法InfluxQL类似SQL
Object result = influxDB.query(new Query(&quot;SELECT MEAN(value),MIN(value),MAX(value) FROM m WHERE time &gt; now() - 60d GROUP BY TIME(1h)&quot;));
log.info(&quot;took {} ms result {}&quot;, System.currentTimeMillis() - begin, result);
}
}
```
因为数据量非常大,单次查询就已经很慢了,所以这次我们不进行压测。分别调用两个接口,可以看到**MySQL查询一次耗时29秒左右而InfluxDB耗时980ms**
```
[16:19:26.562] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.n.i.PerformanceController:31 ] - took 28919 ms result [{date_format(time,'%Y%m%d%H')=2019121308, max(value)=9993, min(value)=4, avg(value)=5129.5639}, {date_format(time,'%Y%m%d%H')=2019121309, max(value)=9990, min(value)=12, avg(value)=4856.0556}, {date_format(time,'%Y%m%d%H')=2019121310, max(value)=9998, min(value)=8, avg(value)=4948.9347}, {date_format(time,'%Y%m%d%H')...
[16:20:08.170] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.n.i.PerformanceController:40 ] - took 981 ms result QueryResult [results=[Result [series=[Series [name=m, tags=null, columns=[time, mean, min, max], values=[[2019-12-13T08:00:00Z, 5249.2468619246865, 21.0, 9992.0],...
```
在按照时间区间聚合的案例上我们看到了InfluxDB的性能优势。但我们**肯定不能把InfluxDB当作普通数据库**,原因是:
- InfluxDB不支持数据更新操作毕竟时间数据只能随着时间产生新数据肯定无法对过去的数据做修改
- 从数据结构上说,时间序列数据数据没有单一的主键标识,必须包含时间戳,数据只能和时间戳进行关联,不适合普通业务数据。
**此外需要注意即便只是使用InfluxDB保存和时间相关的指标数据我们也要注意不能滥用tag**
InfluxDB提供的tag功能可以为每一个指标设置多个标签并且tag有索引可以对tag进行条件搜索或分组。但是tag只能保存有限的、可枚举的标签不能保存URL等信息否则可能会出现[high series cardinality问题](https://docs.influxdata.com/influxdb/v1.7/concepts/schema_and_data_layout/#don-t-have-too-many-serieshigh%20series%20cardinality)导致占用大量内存甚至是OOM。你可以点击[这里](https://docs.influxdata.com/influxdb/v1.7/guides/hardware_sizing/)查看series和内存占用的关系。对于InfluxDB我们无法把URL这种原始数据保存到数据库中只能把数据进行归类形成有限的tag进行保存。
总结一下对于MySQL而言针对大量的数据使用全表扫描的方式来聚合统计指标数据性能非常差一般只能作为临时方案来使用。此时引入InfluxDB之类的时间序列数据库就很有必要了。时间序列数据库可以作为特定场景比如监控、统计的主存储也可以和关系型数据库搭配使用作为一个辅助数据源保存业务系统的指标数据。
## 取长补短之 Elasticsearch vs MySQL
Elasticsearch以下简称ES是目前非常流行的分布式搜索和分析数据库独特的倒排索引结构尤其适合进行全文搜索。
简单来讲倒排索引可以认为是一个Map其Key是分词之后的关键字Value是文档ID/片段ID的列表。我们只要输入需要搜索的单词就可以直接在这个Map中得到所有包含这个单词的文档ID/片段ID列表然后再根据其中的文档ID/片段ID查询出实际的文档内容。
我们来测试一下对比下使用ES进行关键字全文搜索、在MySQL中使用LIKE进行搜索的效率差距。
首先定义一个实体News包含新闻分类、标题、内容等字段。这个实体同时会用作Spring Data JPA和Spring Data Elasticsearch的实体
```
@Entity
@Document(indexName = &quot;news&quot;, replicas = 0) //@Document注解定义了这是一个ES的索引索引名称news数据不需要冗余
@Table(name = &quot;news&quot;, indexes = {@Index(columnList = &quot;cateid&quot;)}) //@Table注解定义了这是一个MySQL表表名news对cateid列做索引
@Data
@AllArgsConstructor
@NoArgsConstructor
@DynamicUpdate
public class News {
@Id
private long id;
@Field(type = FieldType.Keyword)
private String category;//新闻分类名称
private int cateid;//新闻分类ID
@Column(columnDefinition = &quot;varchar(500)&quot;)//@Column注解定义了在MySQL中字段比如这里定义title列的类型是varchar(500)
@Field(type = FieldType.Text, analyzer = &quot;ik_max_word&quot;, searchAnalyzer = &quot;ik_smart&quot;)//@Field注解定义了ES字段的格式使用ik分词器进行分词
private String title;//新闻标题
@Column(columnDefinition = &quot;text&quot;)
@Field(type = FieldType.Text, analyzer = &quot;ik_max_word&quot;, searchAnalyzer = &quot;ik_smart&quot;)
private String content;//新闻内容
}
```
接下来我们实现主程序。在启动时我们会从一个csv文件中加载4000条新闻数据然后复制100份拼成40万条数据分别写入MySQL和ES
```
@SpringBootApplication
@Slf4j
@EnableElasticsearchRepositories(includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = NewsESRepository.class)) //明确设置哪个是ES的Repository
@EnableJpaRepositories(excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = NewsESRepository.class)) //其他的是MySQL的Repository
public class CommonMistakesApplication {
public static void main(String[] args) {
Utils.loadPropertySource(CommonMistakesApplication.class, &quot;es.properties&quot;);
SpringApplication.run(CommonMistakesApplication.class, args);
}
@Autowired
private StandardEnvironment standardEnvironment;
@Autowired
private NewsESRepository newsESRepository;
@Autowired
private NewsMySQLRepository newsMySQLRepository;
@PostConstruct
public void init() {
//使用-Dspring.profiles.active=init启动程序进行初始化
if (Arrays.stream(standardEnvironment.getActiveProfiles()).anyMatch(s -&gt; s.equalsIgnoreCase(&quot;init&quot;))) {
//csv中的原始数据只有4000条
List&lt;News&gt; news = loadData();
AtomicLong atomicLong = new AtomicLong();
news.forEach(item -&gt; item.setTitle(&quot;%%&quot; + item.getTitle()));
//我们模拟100倍的数据量也就是40万条
IntStream.rangeClosed(1, 100).forEach(repeat -&gt; {
news.forEach(item -&gt; {
//重新设置主键ID
item.setId(atomicLong.incrementAndGet());
//每次复制数据稍微改一下title字段在前面加上一个数字代表这是第几次复制
item.setTitle(item.getTitle().replaceFirst(&quot;%%&quot;, String.valueOf(repeat)));
});
initMySQL(news, repeat == 1);
log.info(&quot;init MySQL finished for {}&quot;, repeat);
initES(news, repeat == 1);
log.info(&quot;init ES finished for {}&quot;, repeat);
});
}
}
//从news.csv中解析得到原始数据
private List&lt;News&gt; loadData() {
//使用jackson-dataformat-csv实现csv到POJO的转换
CsvMapper csvMapper = new CsvMapper();
CsvSchema schema = CsvSchema.emptySchema().withHeader();
ObjectReader objectReader = csvMapper.readerFor(News.class).with(schema);
ClassLoader classLoader = getClass().getClassLoader();
File file = new File(classLoader.getResource(&quot;news.csv&quot;).getFile());
try (Reader reader = new FileReader(file)) {
return objectReader.&lt;News&gt;readValues(reader).readAll();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
//把数据保存到ES中
private void initES(List&lt;News&gt; news, boolean clear) {
if (clear) {
//首次调用的时候先删除历史数据
newsESRepository.deleteAll();
}
newsESRepository.saveAll(news);
}
//把数据保存到MySQL中
private void initMySQL(List&lt;News&gt; news, boolean clear) {
if (clear) {
//首次调用的时候先删除历史数据
newsMySQLRepository.deleteAll();
}
newsMySQLRepository.saveAll(news);
}
}
```
由于我们使用了Spring Data直接定义两个Repository然后直接定义查询方法无需实现任何逻辑即可实现查询Spring Data会根据方法名生成相应的SQL语句和ES查询DSL其中ES的翻译逻辑[详见这里](https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/#elasticsearch.query-methods.criterions)。
在这里我们定义一个countByCateidAndContentContainingAndContentContaining方法代表查询条件是搜索分类等于cateid参数且内容同时包含关键字keyword1和keyword2计算符合条件的新闻总数量
```
@Repository
public interface NewsMySQLRepository extends JpaRepository&lt;News, Long&gt; {
//JPA搜索分类等于cateid参数且内容同时包含关键字keyword1和keyword2计算符合条件的新闻总数量
long countByCateidAndContentContainingAndContentContaining(int cateid, String keyword1, String keyword2);
}
@Repository
public interface NewsESRepository extends ElasticsearchRepository&lt;News, Long&gt; {
//ES搜索分类等于cateid参数且内容同时包含关键字keyword1和keyword2计算符合条件的新闻总数量
long countByCateidAndContentContainingAndContentContaining(int cateid, String keyword1, String keyword2);
}
```
对于ES和MySQL我们使用相同的条件进行搜索搜素分类是1关键字是社会和苹果然后输出搜索结果和耗时
```
//测试MySQL搜索最后输出耗时和结果
@GetMapping(&quot;mysql&quot;)
public void mysql(@RequestParam(value = &quot;cateid&quot;, defaultValue = &quot;1&quot;) int cateid,
@RequestParam(value = &quot;keyword1&quot;, defaultValue = &quot;社会&quot;) String keyword1,
@RequestParam(value = &quot;keyword2&quot;, defaultValue = &quot;苹果&quot;) String keyword2) {
long begin = System.currentTimeMillis();
Object result = newsMySQLRepository.countByCateidAndContentContainingAndContentContaining(cateid, keyword1, keyword2);
log.info(&quot;took {} ms result {}&quot;, System.currentTimeMillis() - begin, result);
}
//测试ES搜索最后输出耗时和结果
@GetMapping(&quot;es&quot;)
public void es(@RequestParam(value = &quot;cateid&quot;, defaultValue = &quot;1&quot;) int cateid,
@RequestParam(value = &quot;keyword1&quot;, defaultValue = &quot;社会&quot;) String keyword1,
@RequestParam(value = &quot;keyword2&quot;, defaultValue = &quot;苹果&quot;) String keyword2) {
long begin = System.currentTimeMillis();
Object result = newsESRepository.countByCateidAndContentContainingAndContentContaining(cateid, keyword1, keyword2);
log.info(&quot;took {} ms result {}&quot;, System.currentTimeMillis() - begin, result);
}
```
分别调用接口可以看到,**ES耗时仅仅48msMySQL耗时6秒多是ES的100倍**。很遗憾虽然新闻分类ID已经建了索引但是这个索引只能起到加速过滤分类ID这一单一条件的作用对于文本内容的全文搜索B+树索引无能为力。
```
[22:04:00.951] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.n.esvsmyql.PerformanceController:48 ] - took 48 ms result 2100
Hibernate: select count(news0_.id) as col_0_0_ from news news0_ where news0_.cateid=? and (news0_.content like ? escape ?) and (news0_.content like ? escape ?)
[22:04:11.946] [http-nio-45678-exec-7] [INFO ] [o.g.t.c.n.esvsmyql.PerformanceController:39 ] - took 6637 ms result 2100
```
但ES这种以索引为核心的数据库也不是万能的频繁更新就是一个大问题。
MySQL可以做到仅更新某行数据的某个字段但ES里每次数据字段更新都相当于整个文档索引重建。即便ES提供了文档部分更新的功能但本质上只是节省了提交文档的网络流量以及减少了更新冲突其内部实现还是文档删除后重新构建索引。因此如果要在ES中保存一个类似计数器的值要实现不断更新其执行效率会非常低。
我们来验证下分别使用JdbcTemplate+SQL语句、ElasticsearchTemplate+自定义UpdateQuery实现部分更新MySQL表和ES索引的一个字段每个方法都是循环更新1000次
```
@GetMapping(&quot;mysql2&quot;)
public void mysql2(@RequestParam(value = &quot;id&quot;, defaultValue = &quot;400000&quot;) long id) {
long begin = System.currentTimeMillis();
//对于MySQL使用JdbcTemplate+SQL语句实现直接更新某个category字段更新1000次
IntStream.rangeClosed(1, 1000).forEach(i -&gt; {
jdbcTemplate.update(&quot;UPDATE `news` SET category=? WHERE id=?&quot;, new Object[]{&quot;test&quot; + i, id});
});
log.info(&quot;mysql took {} ms result {}&quot;, System.currentTimeMillis() - begin, newsMySQLRepository.findById(id));
}
@GetMapping(&quot;es2&quot;)
public void es(@RequestParam(value = &quot;id&quot;, defaultValue = &quot;400000&quot;) long id) {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, 1000).forEach(i -&gt; {
//对于ES通过ElasticsearchTemplate+自定义UpdateQuery实现文档的部分更新
UpdateQuery updateQuery = null;
try {
updateQuery = new UpdateQueryBuilder()
.withIndexName(&quot;news&quot;)
.withId(String.valueOf(id))
.withType(&quot;_doc&quot;)
.withUpdateRequest(new UpdateRequest().doc(
jsonBuilder()
.startObject()
.field(&quot;category&quot;, &quot;test&quot; + i)
.endObject()))
.build();
} catch (IOException e) {
e.printStackTrace();
}
elasticsearchTemplate.update(updateQuery);
});
log.info(&quot;es took {} ms result {}&quot;, System.currentTimeMillis() - begin, newsESRepository.findById(id).get());
}
```
可以看到,**MySQL耗时仅仅1.5秒而ES耗时6.8秒**
<img src="https://static001.geekbang.org/resource/image/63/02/63a583a0bced67a3f7cf0eb32e644802.png" alt="">
ES是一个分布式的全文搜索数据库所以与MySQL相比的优势在于文本搜索而且因为其分布式的特性可以使用一个大ES集群处理大规模数据的内容搜索。但由于ES的索引是文档维度的所以不适用于频繁更新的OLTP业务。
一般而言我们会把ES和MySQL结合使用MySQL直接承担业务系统的增删改操作而ES作为辅助数据库直接扁平化保存一份业务数据用于复杂查询、全文搜索和统计。接下来我也会继续和你分析这一点。
## 结合NoSQL和MySQL应对高并发的复合数据库架构
现在我们通过一些案例看到了Redis、InfluxDB、ES这些NoSQL数据库都有擅长和不擅长的场景。那么有没有全能的数据库呢
我认为没有。每一个存储系统都有其独特的数据结构,数据结构的设计就决定了其擅长和不擅长的场景。
比如MySQL InnoDB引擎的B+树对排序和范围查询友好频繁数据更新的代价不是太大因此适合OLTPOn-Line Transaction Processing
又比如ES的Lucene采用了FSTFinite State Transducer索引+倒排索引,空间效率高,适合对变动不频繁的数据做索引,实现全文搜索。存储系统本身不可能对一份数据使用多种数据结构保存,因此不可能适用于所有场景。
虽然在大多数业务场景下MySQL的性能都不算太差但对于数据量大、访问量大、业务复杂的互联网应用来说MySQL因为实现了ACID原子性、一致性、隔离性、持久性会比较重而且横向扩展能力较差、功能单一无法扛下所有数据量和流量无法应对所有功能需求。因此我们需要通过架构手段来组合使用多种存储系统取长补短实现1+1&gt;2的效果。
我来举个例子。我们设计了一个**包含多个数据库系统的、能应对各种高并发场景的一套数据服务的系统架构**,其中包含了同步写服务、异步写服务和查询服务三部分,分别实现主数据库写入、辅助数据库写入和查询路由。
我们按照服务来依次分析下这个架构。
<img src="https://static001.geekbang.org/resource/image/bb/38/bbbcdbd74308de6b8fda04b34ed07e38.png" alt="">
首先要明确的是重要的业务主数据只能保存在MySQL这样的关系型数据库中原因有三点
- RDBMS经过了几十年的验证已经非常成熟
- RDBMS的用户数量众多Bug修复快、版本稳定、可靠性很高
- RDBMS强调ACID能确保数据完整。
有两种类型的查询任务可以交给MySQL来做性能会比较好这也是MySQL擅长的地方
- 按照主键ID的查询。直接查询聚簇索引其性能会很高。但是单表数据量超过亿级后性能也会衰退而且单个数据库无法承受超大的查询并发因此我们可以把数据表进行Sharding操作均匀拆分到多个数据库实例中保存。我们把这套数据库集群称作Sharding集群。
- 按照各种条件进行范围查询查出主键ID。对二级索引进行查询得到主键只需要查询一棵B+树效率同样很高。但索引的值不宜过大比如对varchar(1000)进行索引不太合适而索引外键一般是int或bigint类型性能就会比较好。因此我们可以在MySQL中建立一张“索引表”除了保存主键外主要是保存各种关联表的外键以及尽可能少的varchar类型的字段。这张索引表的大部分列都可以建上二级索引用于进行简单搜索搜索的结果是主键的列表而不是完整的数据。由于索引表字段轻量并且数量不多一般控制在10个以内所以即便索引表没有进行Sharding拆分问题也不会很大。
如图上蓝色线所示写入两种MySQL数据表和发送MQ消息的这三步我们用一个**同步写服务**完成了。我在“[异步处理](https://time.geekbang.org/column/article/234928)”中提到,所有异步流程都需要补偿,这里的异步流程同样需要。只不过为了简洁,我在这里省略了补偿流程。
然后,如图中绿色线所示,有一个**异步写服务**监听MQ的消息继续完成辅助数据的更新操作。这里我们选用了ES和InfluxDB这两种辅助数据库因此整个异步写数据操作有三步
1. MQ消息不一定包含完整的数据甚至可能只包含一个最新数据的主键ID我们需要根据ID从查询服务查询到完整的数据。
1. 写入InfluxDB的数据一般可以按时间间隔进行简单聚合定时写入InfluxDB。因此这里会进行简单的客户端聚合然后写入InfluxDB。
1. ES不适合在各索引之间做连接Join操作适合保存扁平化的数据。比如我们可以把订单下的用户、商户、商品列表等信息作为内嵌对象嵌入整个订单JSON然后把整个扁平化的JSON直接存入ES。
对于数据写入操作,我们认为操作返回的时候同步数据一定是写入成功的,但是由于各种原因,异步数据写入无法确保立即成功,会有一定延迟,比如:
- 异步消息丢失的情况,需要补偿处理;
- 写入ES的索引操作本身就会比较慢
- 写入InfluxDB的数据需要客户端定时聚合。
因此,对于**查询服务**,如图中红色线所示,我们需要根据一定的上下文条件(比如查询一致性要求、时效性要求、搜索的条件、需要返回的数据字段、搜索时间区间等)来把请求路由到合适的数据库,并且做一些聚合处理:
- 需要根据主键查询单条数据可以从MySQL Sharding集群或Redis查询如果对实时性要求不高也可以从ES查询。
- 按照多个条件搜索订单的场景可以从MySQL索引表查询出主键列表然后再根据主键从MySQL Sharding集群或Redis获取数据详情。
- 各种后台系统需要使用比较复杂的搜索条件甚至全文搜索来查询订单数据或是定时分析任务需要一次查询大量数据这些场景对数据实时性要求都不高可以到ES进行搜索。此外MySQL中的数据可以归档我们可以在ES中保留更久的数据而且查询历史数据一般并发不会很大可以统一路由到ES查询。
- 监控系统或后台报表系统需要呈现业务监控图表或表格可以把请求路由到InfluxDB查询。
## 重点回顾
今天我通过三个案例分别对比了缓存数据库Redis、时间序列数据库InfluxDB、搜索数据库ES和MySQL的性能。我们看到
- Redis对单条数据的读取性能远远高于MySQL但不适合进行范围搜索。
- InfluxDB对于时间序列数据的聚合效率远远高于MySQL但因为没有主键所以不是一个通用数据库。
- ES对关键字的全文搜索能力远远高于MySQL但是字段的更新效率较低不适合保存频繁更新的数据。
最后我们给出了一个混合使用MySQL + Redis + InfluxDB + ES的架构方案充分发挥了各种数据库的特长相互配合构成了一个可以应对各种复杂查询以及高并发读写的存储架构。
- 主数据由两种MySQL数据表构成其中索引表承担简单条件的搜索来得到主键Sharding表承担大并发的主键查询。主数据由同步写服务写入写入后发出MQ消息。
- 辅助数据可以根据需求选用合适的NoSQL由单独一个或多个异步写服务监听MQ后异步写入。
- 由统一的查询服务,对接所有查询需求,根据不同的查询需求路由查询到合适的存储,确保每一个存储系统可以根据场景发挥所长,并分散各数据库系统的查询压力。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 我们提到InfluxDB不能包含太多tag。你能写一段测试代码来模拟这个问题并观察下InfluxDB的内存使用情况吗
1. 文档数据库MongoDB也是一种常用的NoSQL。你觉得MongoDB的优势和劣势是什么呢它适合用在什么场景下呢
关于数据存储,你还有其他心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,338 @@
<audio id="audio" title="答疑篇:设计篇思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/8a/d24c6131ca3286dc01e105d808a0078a.mp3"></audio>
你好,我是朱晔。
今天我们继续一起分析这门课“设计篇”模块的第21~26讲的课后思考题。这些题目涉及了代码重复、接口设计、缓存设计、生产就绪、异步处理和数据存储这6大知识点。
接下来,我们就一一具体分析吧。
### [21 | 代码重复:搞定代码重复的三个绝招](https://time.geekbang.org/column/article/228964)
**问题1**除了模板方法设计模式是减少重复代码的一把好手观察者模式也常用于减少代码重复并且是松耦合方式Spring 也提供了类似工具(点击[这里](https://docs.spring.io/spring/docs/5.2.3.RELEASE/spring-framework-reference/core.html#context-functionality-events-annotation)查看),你能想到观察者模式有哪些应用场景吗?
其实和使用MQ来解耦系统和系统的调用类似应用内各个组件之间的调用我们也可以使用观察者模式来解耦特别是当你的应用是一个大单体的时候。观察者模式除了是让组件之间可以更松耦合还能更有利于消除重复代码。
其原因是,对于一个复杂的业务逻辑,里面必然涉及到大量其它组件的调用,虽然我们没有重复写这些组件内部处理逻辑的代码,但是这些复杂调用本身就构成了重复代码。
我们可以考虑把代码逻辑抽象一下,抽象出许多事件,围绕这些事件来展开处理,那么这种处理模式就从“命令式”变为了“环境感知式”,每一个组件就好像活在一个场景中,感知场景中的各种事件,然后又把发出处理结果作为另一个事件。
经过这种抽象,复杂组件之间的调用逻辑就变成了“事件抽象+事件发布+事件订阅”,整个代码就会更简化。
补充说明一下,除了观察者模式我们还经常听到发布订阅模式,那么它们有什么区别呢?
其实观察者模式也可以叫做发布订阅模式。不过在严格定义上前者属于松耦合后者必须要MQ Broker的介入实现发布者订阅者的完全解耦。
**问题2**关于 Bean 属性复制工具,除了最简单的 Spring 的 BeanUtils 工具类的使用,你还知道哪些对象映射类库吗?它们又有什么功能呢?
答:在众多对象映射工具中,[MapStruct](https://github.com/mapstruct/mapstruct)更具特色一点。它基于JSR 269的Java注解处理器实现你可以理解为它是编译时的代码生成器使用的是纯Java方法而不是反射进行属性赋值并且做到了编译时类型安全。
如果你使用IDEA的话可以进一步安装 [IDEA MapStruct Support插件](https://plugins.jetbrains.com/plugin/10036-mapstruct-support),实现映射配置的自动完成、跳转到定义等功能。关于这个插件的具体功能,你可以参考[这里](https://mapstruct.org/news/2017-09-19-announcing-mapstruct-idea/)。
### [22 | 接口设计:系统间对话的语言,一定要统一](https://time.geekbang.org/column/article/228968)
**问题1**在“接口的响应要明确表示接口的处理结果”这一节的例子中接口响应结构体中的code字段代表执行结果的错误码对于业务特别复杂的接口可能会有很多错误情况code可能会有几十甚至几百个。客户端开发人员需要根据每一种错误情况逐一写if-else进行不同交互处理会非常麻烦你觉得有什么办法来改进吗作为服务端是否有必要告知客户端接口执行的错误码呢
答:服务端把错误码反馈给客户端有两个目的,一是客户端可以展示错误码方便排查问题,二是客户端可以根据不同的错误码来做交互区分。
**对于第一点方便客户端排查问题**,服务端应该进行适当的收敛和规整错误码,而不是把服务内可能遇到的、来自各个系统各个层次的错误码,一股脑地扔给客户端提示给用户。
我的建议是,开发一个错误码服务来专门治理错误码,实现错误码的转码、分类和收敛逻辑,甚至可以开发后台,让产品来录入需要的错误码提示消息。
此外我还建议错误码由一定的规则构成比如错误码第一位可以是错误类型比如A表示错误来源于用户B表示错误来源于当前系统往往是业务逻辑出错或程序健壮性差等问题C表示错误来源于第三方服务第二、第三位可以是错误来自的系统编号比如01来自用户服务02来自商户服务等等后面三位是自增错误码ID。
**对于第二点对不同错误码的交互区分**,我觉得更好的做法是服务端驱动模式,让服务端告知客户端如何处理,说白了就是客户端只需要照做即可,不需要感知错误码的含义(即便客户端显示错误码,也只是用于排错)。
比如服务端的返回可以包含actionType和actionInfo两个字段前者代表客户端应该做的交互动作后者代表客户端完成这个交互动作需要的信息。其中actionType可以是toast无需确认的消息提示、alert需要确认的弹框提示、redirectView转到另一个视图、redirectWebView打开Web视图actionInfo就是toast的信息、alert的信息、redirect的URL等。
由服务端来明确客户端在请求API后的交互行为主要的好处是灵活和统一两个方面。
- 灵活在于两个方面第一在紧急的时候还可以通过redirect方式进行救急。比如遇到特殊情况需要紧急进行逻辑修改的情况时我们可以直接在不发版的情况下切换到H5实现。第二是我们可以提供后台让产品或运营来配置交互的方式和信息而不是改交互改提示还需要客户端发版
- 统一有的时候会遇到不同的客户端比如iOS、Android、前端对于交互的实现不统一的情况如果API结果可以规定这部分内容那就可以彻底避免这个问题。
**问题2**在“要考虑接口变迁的版本控制策略”这一节的例子中,我们在类或方法上标记@APIVersion自定义注解实现了URL方式统一的接口版本定义。你可以用类似的方式也就是自定义RequestMappingHandlerMapping来实现一套统一的基于请求头方式的版本控制吗
我在GitHub上第21讲的源码中更新了我的实现你可以点击[这里](https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/apidesign/headerapiversion)查看。主要原理是定义自己的RequestCondition来做请求头的匹配
```
public class APIVersionCondition implements RequestCondition&lt;APIVersionCondition&gt; {
@Getter
private String apiVersion;
@Getter
private String headerKey;
public APIVersionCondition(String apiVersion, String headerKey) {
this.apiVersion = apiVersion;
this.headerKey = headerKey;
}
@Override
public APIVersionCondition combine(APIVersionCondition other) {
return new APIVersionCondition(other.getApiVersion(), other.getHeaderKey());
}
@Override
public APIVersionCondition getMatchingCondition(HttpServletRequest request) {
String version = request.getHeader(headerKey);
return apiVersion.equals(version) ? this : null;
}
@Override
public int compareTo(APIVersionCondition other, HttpServletRequest request) {
return 0;
}
}
```
并且自定义RequestMappingHandlerMapping来把方法关联到自定义的RequestCondition
```
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class&lt;?&gt; beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected RequestCondition&lt;APIVersionCondition&gt; getCustomTypeCondition(Class&lt;?&gt; handlerType) {
APIVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, APIVersion.class);
return createCondition(apiVersion);
}
@Override
protected RequestCondition&lt;APIVersionCondition&gt; getCustomMethodCondition(Method method) {
APIVersion apiVersion = AnnotationUtils.findAnnotation(method, APIVersion.class);
return createCondition(apiVersion);
}
private RequestCondition&lt;APIVersionCondition&gt; createCondition(APIVersion apiVersion) {
return apiVersion == null ? null : new APIVersionCondition(apiVersion.value(), apiVersion.headerKey());
}
}
```
### [23 | 缓存设计:缓存可以锦上添花也可以落井下石](https://time.geekbang.org/column/article/231501)
**问题1**在聊到缓存并发问题时,我们说到热点 Key 回源会对数据库产生的压力问题,如果 Key 特别热的话,可能缓存系统也无法承受,毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用 Redis 来做缓存,那可以把一个热点 Key 的缓存查询压力,分散到多个 Redis 节点上吗?
Redis 4.0以上如果开启了LFU算法作为maxmemory-policy那么可以使用hotkeys配合redis-cli命令行工具来探查热点Key。此外我们还可以通过MONITOR命令来收集Redis执行的所有命令然后配合[redis-faina工具](https://github.com/facebookarchive/redis-faina)来分析热点Key、热点前缀等信息。
对于如何分散热点Key对于Redis单节点的压力的问题我们可以考虑为Key加上一定范围的随机数作为后缀让一个Key变为多个Key相当于对热点Key进行分区操作。
当然除了分散Redis压力之外我们也可以考虑再做一层短时间的本地缓存结合Redis的Keyspace通知功能来处理本地缓存的数据同步。
**问题2**大 Key 也是数据缓存容易出现的一个问题。如果一个 Key 的 Value 特别大,那么可能会对 Redis 产生巨大的性能影响,因为 Redis 是单线程模型,对大 Key 进行查询或删除等操作,可能会引起 Redis 阻塞甚至是高可用切换。你知道怎么查询 Redis 中的大 Key以及如何在设计上实现大 Key 的拆分吗?
Redis的大Key可能会导致集群内存分布不均问题并且大Key的操作可能也会产生阻塞。
关于查询Redis中的大Key我们可以使用redis-cli --bigkeys命令来实时探查大Key。此外我们还可以使用redis-rdb-tools工具来分析Redis的RDB快照得到包含Key的字节数、元素个数、最大元素长度等信息的CSV文件。然后我们可以把这个CSV文件导入MySQL中写SQL去分析。
针对大Key我们可以考虑两方面的优化
- 第一是否有必要在Redis保存这么多数据。一般情况下我们在缓存系统中保存面向呈现的数据而不是原始数据对于原始数据的计算我们可以考虑其它文档型或搜索型的NoSQL数据库。
- 第二考虑把具有二级结构的Key比如List、Set、Hash拆分成多个小Key来独立获取或是用MGET获取
此外值得一提的是大Key的删除操作可能会产生较大性能问题。从Redis 4.0开始我们可以使用UNLINK命令而不是DEL命令在后台删除大Key而对于4.0之前的版本我们可以考虑使用游标删除大Key中的数据而不是直接使用DEL命令比如对于Hash使用HSCAN+HDEL结合管道功能来删除。
### [24 | 业务代码写完,就意味着生产就绪了?](https://time.geekbang.org/column/article/231568)
**问题1**Spring Boot Actuator提供了大量内置端点你觉得端点和自定义一个@RestController有什么区别呢?你能否根据[官方文档](https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html#production-ready-endpoints-custom),开发一个自定义端点呢?
Endpoint是Spring Boot Actuator抽象出来的一个概念主要用于监控和配置。使用@Endpoint注解自定义端点,配合方法上的@ReadOperation@WriteOperation@DeleteOperation注解分分钟就可以开发出自动通过HTTP或JMX进行暴露的监控点。
如果只希望通过HTTP暴露的话可以使用@WebEndpoint注解如果只希望通过JMX暴露的话可以使用@JmxEndpoint注解
而使用@RestController一般用于定义业务接口如果数据需要暴露到JMX的话需要手动开发。
比如,下面这段代码展示了如何定义一个累加器端点,提供了读取操作和累加两个操作:
```
@Endpoint(id = &quot;adder&quot;)
@Component
public class TestEndpoint {
private static AtomicLong atomicLong = new AtomicLong();
//读取值
@ReadOperation
public String get() {
return String.valueOf(atomicLong.get());
}
//累加值
@WriteOperation
public String increment() {
return String.valueOf(atomicLong.incrementAndGet());
}
}
```
然后我们可以通过HTTP或JMX来操作这个累加器。这样我们就实现了一个自定义端点并且可以通过JMX来操作
<img src="https://static001.geekbang.org/resource/image/c4/0a/c46526acec7d7b72714b73073ee42f0a.png" alt="">
**问题2**在介绍指标Metrics时我们看到InfluxDB中保存了由Micrometer框架自动帮我们收集的一些应用指标。你能否参考源码中两个Grafana配置的JSON文件把这些指标在Grafana中配置出一个完整的应用监控面板呢
答:我们可以参考[Micrometer源码中的binder包](https://github.com/micrometer-metrics/micrometer/tree/master/micrometer-core/src/main/java/io/micrometer/core/instrument/binder)下面的类来了解Micrometer帮我们自动做的一些指标。
- JVM在线时间process.uptime
- 系统CPU使用system.cpu.usage
- JVM进程CPU使用process.cpu.usage
- 系统1分钟负载system.load.average.1m
- JVM使用内存jvm.memory.used
- JVM提交内存jvm.memory.committed
- JVM最大内存jvm.memory.max
- JVM线程情况jvm.threads.states
- JVM GC暂停jvm.gc.pause、jvm.gc.concurrent.phase.time
- 剩余磁盘disk.free
- Logback日志数量logback.events
- Tomcat线程情况最大、繁忙、当前tomcat.threads.config.max、tomcat.threads.busy、tomcat.threads.current
具体的面板配置方式,[第24讲](https://time.geekbang.org/column/article/231568)中已有说明。这里,我只和你分享在配置时会用到的两个小技巧。
第一个小技巧是把公共的标签配置为下拉框固定在页头显示一般来说我们会配置一个面板给所有的应用使用每一个指标中我们都会保存应用名称、IP地址等信息这个功能可以使用Micrometer的CommonTags实现参考[文档](http://micrometer.io/docs/concepts)的5.2节我们可以利用Grafana的[Variables](https://grafana.com/docs/grafana/latest/variables/templates-and-variables/)功能把应用名称和IP展示为两个下拉框显示同时提供一个adhoc筛选器自由增加筛选条件
<img src="https://static001.geekbang.org/resource/image/4e/d0/4e6255c68aeecd241cd7629321c5e2d0.png" alt="">
来到Variables面板可以看到我配置的三个变量
<img src="https://static001.geekbang.org/resource/image/49/29/493492d36405c8f9ed31eb2924276729.png" alt="">
Application和IP两个变量的查询语句如下
```
SHOW TAG VALUES FROM jvm_memory_used WITH KEY = &quot;application_name&quot;
SHOW TAG VALUES FROM jvm_memory_used WITH KEY = &quot;ip&quot; WHERE application_name=~ /^$Application$/
```
第二个小技巧是利用GROUP BY功能展示一些明细的曲线类似jvm_threads_states、jvm.gc.pause等指标中包含了更细节的一些状态区分标签比如jvm_threads_states中的state标签代表了线程状态。一般而言我们在展现图表的时候需要按照线程状态分组分曲线显示
<img src="https://static001.geekbang.org/resource/image/bc/62/bc74c6yy84d233c429258406794a5262.png" alt="">
配置的InfluxDB查询语句是
```
SELECT max(&quot;value&quot;) FROM &quot;jvm_threads_states&quot; WHERE (&quot;application_name&quot; =~ /^$Application$/ AND &quot;ip&quot; =~ /^$IP$/) AND $timeFilter GROUP BY time($__interval), &quot;state&quot; fill(none)
```
这里可以看到application_name和ip两个条件的值是关联到刚才我们配置的两个变量的在GROUP BY中增加了按照state的分组。
### [25 | 异步处理好用,但非常容易用错](https://time.geekbang.org/column/article/234928)
**问题1**在用户注册后发送消息到MQ然后会员服务监听消息进行异步处理的场景下有些时候我们会发现虽然用户服务先保存数据再发送MQ但会员服务收到消息后去查询数据库却发现数据库中还没有新用户的信息。你觉得这可能是什么问题呢又该如何解决呢
答:我先来分享下,我遇到这个问题的真实情况。
当时我们是因为业务代码把保存数据和发MQ消息放在了一个事务中收到消息的时候有可能事务还没有提交完成。为了解决这个问题开发同学当时的处理方式是收MQ消息的时候Sleep 1秒再去处理。这样虽然解决了问题但却大大降低了消息处理的吞吐量。
更好的做法是先提交事务完成后再发MQ消息。但是这又引申出来一个问题MQ消息发送失败怎么办如何确保发送消息和本地事务有整体事务性这就需要进一步考虑建立本地消息表来确保MQ消息可补偿把业务处理和保存MQ消息到本地消息表的操作放在相同事务内处理然后异步发送和补偿消息表中的消息到MQ。
**问题2**除了使用Spring AMQP实现死信消息的重投递外RabbitMQ 2.8.0 后支持的死信交换器DLX也可以实现类似功能。你能尝试用DLX实现吗并比较下这两种处理机制
其实RabbitMQ的[DLX死信交换器](https://www.rabbitmq.com/dlx.html)和普通交换器没有什么区别只不过它有一个特点是可以把其它队列关联到这个DLX交换器上然后消息过期后自动会转发到DLX交换器。那么我们就可以利用这个特点来实现延迟消息重投递经过一定次数之后还是处理失败则作为死信处理。
实现结构如下图所示:
<img src="https://static001.geekbang.org/resource/image/41/36/4139d9cbefdabbc793340ddec182a936.png" alt="">
关于这个实现架构图,我需要说明的是:
- 为了简单起见,图中圆柱体代表交换器+队列并省去了RoutingKey。
- WORKER作为DLX用于处理消息BUFFER用于临时存放需要延迟重试的消息WORKER和BUFFER绑定在一起。
- DEAD用于存放超过重试次数的死信。
- 在这里WORKER其实是一个DLX我们把它绑定到BUFFER实现延迟重试。
通过RabbitMQ实现具有延迟重试功能的消息重试以及最后进入死信队列的整个流程如下
1. 客户端发送记录到WORKER
1. Handler收到消息后处理失败
1. 第一次重试发送消息到BUFFER
1. 3秒后消息过期自动转发到WORKER
1. Handler再次收到消息后处理失败
1. 第二次重试发送消息到BUFFER
1. 3秒后消息过期还是自动转发到WORKER
1. Handler再次收到消息后处理失败达到最大重试次数
1. 发送消息到DEAD作为死信消息
1. DeadHandler收到死信处理比如进行人工处理
整个程序的日志输出如下,可以看到输出日志和我们前面贴出的结构图、详细解释的流程一致:
```
[21:59:48.625] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.r.DeadLetterController:24 ] - Client 发送消息 msg1
[21:59:48.640] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:27 ] - Handler 收到消息msg1
[21:59:48.641] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:33 ] - Handler 消费消息msg1 异常准备重试第1次
[21:59:51.643] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:27 ] - Handler 收到消息msg1
[21:59:51.644] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:33 ] - Handler 消费消息msg1 异常准备重试第2次
[21:59:54.646] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:27 ] - Handler 收到消息msg1
[21:59:54.646] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:40 ] - Handler 消费消息msg1 异常,已重试 2 次,发送到死信队列处理!
[21:59:54.649] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#1-1] [ERROR] [o.g.t.c.a.rabbitmqdlx.MQListener:62 ] - DeadHandler 收到死信消息: msg1
```
接下来,我们再对比下这种实现方式和[第25讲](https://time.geekbang.org/column/article/234928)中Spring重试的区别。其实这两种实现方式的差别很大体现在下面两点。
第一点Spring的重试是在处理的时候在线程内休眠进行延迟重试消息不会重发到MQ我们这个方案中处理失败的消息会发送到RMQ由RMQ做延迟处理。
第二点Spring的重试方案只涉及普通队列和死信队列两个队列或者说交换器我们这个方案的实现中涉及工作队列、缓冲队列用于存放等待延迟重试的消息和死信队列真正需要人工处理的消息三个队列。
当然了如果你希望把存放正常消息的队列和把存放需要重试处理消息的队列区分开的话可以把我们这个方案中的队列再拆分下变为四个队列也就是工作队列、重试队列、缓冲队列关联到重试队列作为DLX和死信队列。
这里我再强调一下虽然说我们利用了RMQ的DLX死信交换器的功能但是我们把DLX当做了工作队列来使用因为我们利用的是其能自动从BUFFER缓冲队列接收过期消息的特性。
这部分源码比较长我直接放在GitHub上了。感兴趣的话你可以点击[这里的链接](https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/asyncprocess/rabbitmqdlx)查看。
### [26 | 数据存储NoSQL与RDBMS如何取长补短、相辅相成](https://time.geekbang.org/column/article/234930)
**问题1**我们提到InfluxDB不能包含太多tag。你能写一段测试代码来模拟这个问题并观察下InfluxDB的内存使用情况吗
我们写一段如下的测试代码向InfluxDB写入大量指标每一条指标关联10个Tag每一个Tag都是100000以内的随机数这种方式会造成[high series cardinality问题](https://docs.influxdata.com/influxdb/v1.7/concepts/schema_and_data_layout/#don-t-have-too-many-serieshigh%20series%20cardinality)从而大量占用InfluxDB的内存。
```
@GetMapping(&quot;influxdbwrong&quot;)
public void influxdbwrong() {
OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient().newBuilder()
.connectTimeout(1, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS);
try (InfluxDB influxDB = InfluxDBFactory.connect(&quot;http://127.0.0.1:8086&quot;, &quot;root&quot;, &quot;root&quot;, okHttpClientBuilder)) {
influxDB.setDatabase(&quot;performance&quot;);
//插入100000条记录
IntStream.rangeClosed(1, 100000).forEach(i -&gt; {
Map&lt;String, String&gt; tags = new HashMap&lt;&gt;();
//每条记录10个tagtag的值是100000以内随机值
IntStream.rangeClosed(1, 10).forEach(j -&gt; tags.put(&quot;tagkey&quot; + i, &quot;tagvalue&quot; + ThreadLocalRandom.current().nextInt(100000)));
Point point = Point.measurement(&quot;bad&quot;)
.tag(tags)
.addField(&quot;value&quot;, ThreadLocalRandom.current().nextInt(10000))
.time(System.currentTimeMillis(), TimeUnit.MILLISECONDS)
.build();
influxDB.write(point);
});
}
}
```
不过因为InfluxDB的默认参数配置限制了Tag的值数量以及数据库Series数量
```
max-values-per-tag = 100000
max-series-per-database = 1000000
```
所以这个程序很快就会出错无法形成OOM你可以把这两个参数改为0来解除这个限制。
继续运行程序我们可以发现InfluxDB占用大量内存最终出现OOM。
**问题2**文档数据库MongoDB也是一种常用的NoSQL。你觉得MongoDB的优势和劣势是什么呢它适合用在什么场景下呢
MongoDB是目前比较火的文档型NoSQL。虽然MongoDB 在4.0版本后具有了事务功能但是它整体的稳定性相比MySQL还是有些差距。因此MongoDB不太适合作为重要数据的主数据库但可以用来存储日志、爬虫等数据重要程度不那么高但写入并发量又很大的场景。
虽然MongoDB的写入性能较高但复杂查询性能却相比Elasticsearch来说没啥优势虽然MongoDB有Sharding功能但是还不太稳定。因此我个人建议在数据写入量不大、更新不频繁并且不需要考虑事务的情况下使用Elasticsearch来替换MongoDB。
以上就是咱们这门课的第21~26讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。