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,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讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。