You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

110 KiB

Java 高级

0. 目录

  • Java并发编程
  • Spring 常见错误笔记
  • 业务编写之坑
    • 代码编写常见问题
    • 代码设计常见问题
    • 安全常见问题

2. Spring 常见错误笔记

2.1 Spring Core - SpringBean 定义常见错误

A. 案例 1隐式扫描不到 Bean 的定义

  • 因为包结构的定义, 造成相关的定义类扫描出现错误
  • 案例解析
    • SpringBootApplication 开启了很多功能,其中一个关键功能就是 ComponentScan 注解的 basePackages 属性指定的扫描包的路径
  • 问题修复
    • 方法1: 检查是否可以手动调节至默认的扫包路径下, ComponentScanAnnotationParser#parse 方法, declaringClass(XxxApplication) 所在的包的路径
    • 方法2: 显式配置 @ComponentScans 来修复问题, 确保所有的需要被扫描到的包都被扫描到
    • PS: 注意仅仅使用 @ComponentScan 一旦显式指定其它包,原来地默认扫描包就被忽略了.

B. 案例 2定义的 Bean 缺少隐式依赖

  • 定义 Spring Bean

@Service // ServiceImpl 因为标记为 @Service 而成为一个 Bean
public class ServiceImpl {
    private String serviceName;
    public ServiceImpl(String serviceName){ //  ServiceImpl 显式定义了一个构造器
        this.serviceName = serviceName;
    }
}

代码报错: Parameter 0 of constructor in com.spring.puzzle.class1.example2.ServiceImpl required a bean of type 'java.lang.String' that could not be found.

  • 案例解析
  • 当创建一个 Bean 时,调用的方法是 AbstractAutowireCapableBeanFactory#createBeanInstance
  • 它主要包含两大基本步骤:寻找构造器和通过反射调用构造器创建实例
    // Candidate constructors for autowiring?
    // Spring 会先执行 determineConstructorsFromBeanPostProcessors 方法来获取构造器
    Constructor<?>[] ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName);
    if (ctors != null || mbd.getResolvedAutowireMode() == AUTOWIRE_CONSTRUCTOR ||
        mbd.hasConstructorArgumentValues() || !ObjectUtils.isEmpty(args)) {
        // 然后通过 autowireConstructor 方法带着构造器去创建实例
        // autowireConstructor 方法要创建实例,不仅需要知道是哪个构造器,还需要知道构造器对应的参数 
        return autowireConstructor(beanName, mbd, ctors, args);
    }
}
  • 参考如下(即 ConstructorResolver#instantiate
	private Object instantiate(
			String beanName, RootBeanDefinition mbd, Constructor<?> constructorToUse, Object[] argsToUse) {
  • 上述方法中存储构造参数的 argsToUse 如何获取呢?
  • 当我们已经知道构造器 ServiceImpl(String serviceName),要创建出 ServiceImpl 实例,如何确定 serviceName 的值是多少?
  • 不能直接显式使用 new 关键字来创建实例
  • Spring 只能是去寻找依赖来作为构造器调用参数
  • 参数如何获取? ConstructorResolver#autowireConstructor
    argsHolder = createArgumentArray(beanName, mbd, resolvedValues, bw, paramTypes, paramNames,
    		getUserDeclaredConstructor(candidate), autowiring, candidates.length == 1);
  • 调用 createArgumentArray 方法来构建调用构造器的参数数组,而这个方法的最终实现是从 BeanFactory 中获取 Bean, org.springframework.beans.factory.support.ConstructorResolver#resolveAutowiredArgument
		try {
			return this.beanFactory.resolveDependency(
					new DependencyDescriptor(param, true), beanName, autowiredBeanNames, typeConverter);
		}
  • 上述的调用即是根据参数来寻找对应的 Bean
  • 问题修正
    • 因为不了解很多隐式的规则:我们定义一个类为 Bean如果再显式定义了构造器那么这个 Bean 在构建时,会自动根据构造器参数定义寻找对应的 Bean然后反射创建出这个 Bean.
    • 了解了这个隐式规则后,解决这个问题就简单多了。我们可以直接定义一个能让 Spring 装配给 ServiceImpl 构造器参数的 Bean
//这个bean装配给ServiceImpl的构造器参数“serviceName”
@Bean
public String serviceName(){
    return "MyServiceName";
}

  • 再次运行程序,发现一切正常了。
  • 我们在使用 Spring 时,不要总想着定义的 Bean 也可以在非 Spring 场合直接用 new 关键字显式使用,这种思路是不可取的
  • 类似的,假设我们不了解 Spring 的隐式规则,在修正问题后,我们可能写出更多看似可以运行的程序
@Service
public class ServiceImpl {
private String serviceName;
    public ServiceImpl(String serviceName){
        this.serviceName = serviceName;
    }
    public ServiceImpl(String serviceName, String otherStringParameter){
        this.serviceName = serviceName;
    }
}
  • 如果我们仍用非 Spring 的思维去审阅这段代码,可能不会觉得有什么问题,毕竟 String 类型可以自动装配了,无非就是增加了一个 String 类型的参数而已。
  • 但是如果你了解 Spring 内部是用反射来构建 Bean 的话,就不难发现问题所在:存在两个构造器,都可以调用时,到底应该调用哪个呢?最终 Spring 无从选择,只能尝试去调用默 认构造器,而这个默认构造器又不存在,所以测试这个程序它会出错。
  • PS: 构造器的参数添加@Autowired(required = false)
  • PS: @Scope(value=ConfigurableBeanFactory.SCOPE_PROTOTYPE)这个是说在每次注入的时候回自动创建一个新的bean实例 @Scope(value=ConfigurableBeanFactory.SCOPE_SINGLETON)单例模式,在整个应用中只能创建一个实例。
  • PS: @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)

C. 案例 3原型 Bean 被固定

  • 在定义 Bean 时,有时候我们会使用原型 Bean例如定义如下
@Service
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ServiceImpl {
}
  • 然后我们按照下面的方式去使用它:
@RestController
public class HelloWorldController {
    @Autowired
    private ServiceImpl serviceImpl;
    @RequestMapping(path = "hi", method = RequestMethod.GET)
    public String hi(){
        return "helloworld, service is : " + serviceImpl;
    }
}

helloworld, service is : com.spring.puzzle.class1.example3.error.ServiceImpl@4908af

  • 很明显,这很可能和我们定义 ServiceImpl 为原型 Bean 的初衷背道而驰
  • 案例解析
  • 当一个属性成员 serviceImpl 声明为 @Autowired 后,那么在创建 HelloWorldController 这个 Bean 时,会先使用构造器反射出实例,然后来装配各个标记 为 @Autowired 的属性成员(装配方法参考 AbstractAutowireCapableBeanFactory#populateBean
  • 具体到执行过程,它会使用很多 BeanPostProcessor 来做完成工作,其中一种是 AutowiredAnnotationBeanPostProcessor 它会通过 DefaultListableBeanFactory#findAutowireCandidates 寻找到 ServiceImpl 类型的 Bean然后设置给对应的属性即 serviceImpl 成员)
  • 关键执行步骤可参考 AutowiredAnnotationBeanPostProcessor.AutowiredFieldElement#inject
    @Override
	protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
		Field field = (Field) this.member;
		Object value;
        //寻找 “bean” 
		if (this.cached) {
			value = resolvedCachedArgument(beanName, this.cachedFieldValue);
		}
		else {
            // ...
			try {
				value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
			}
			catch (BeansException ex) {
				throw new UnsatisfiedDependencyException(null, beanName, new InjectionPoint(field), ex);
			}
            // ...
		}
		if (value != null) {
            //将bean设置给成员字段
			ReflectionUtils.makeAccessible(field);
			field.set(bean, value);
		}
	}
}
  • 待我们寻找到要自动注入的 Bean 后,即可通过反射设置给对应的 field.这个 field 的执行只发生了一次,所以后续就固定起来了,这个 field 的执 行只发生了一次,所以后续就固定起来了
  • 所以,当一个单例的 Bean使用 autowired 注解标记其属性时,你一定要注意这个属性值会被固定下来。
  • 问题修正
  • 我们可以知道要修正这个问题,肯定是不能将 ServiceImpl 的 Bean 固定到属性上的,而应该是每次使用时都会重新获取一次。
  • 修复方法1: 自动注入 Context
  • 即自动注入 ApplicationContext然后定义 getServiceImpl() 方法,在方法中获取一个新的 ServiceImpl 类型实例。修正代码如下:
@RestController
public class HelloWorldController {
    @Autowired
    private ApplicationContext applicationContext;

    @RequestMapping(path = "hi", method = RequestMethod.GET)
    public String hi(){
      return "helloworld, service is : " + getServiceImpl();
    }

  public ServiceImpl getServiceImpl(){
    return applicationContext.getBean(ServiceImpl.class);
  }
  • 修复方法2: 使用 Lookup 注解
  • 类似修正方法 1也添加一个 getServiceImpl 方法,不过这个方法是被 Lookup 标记的。
  • 修正代码如下:
@RestController
public class HelloWorldController {
  
  @RequestMapping(path = "hi", method = RequestMethod.GET)
  public String hi(){
    return "helloworld, service is : " + getServiceImpl();
  }
  
  @Lookup
  public ServiceImpl getServiceImpl(){
    return null;
  }
}
  • 通过这两种修正方式,再次测试程序,我们会发现结果已经符合预期(每次访问这个接口,都会创建新的 Bean
  • 讨论下 Lookup 是如何生效的。毕竟在修正代码中,我们看到 getServiceImpl 方法的实现返回值是 null这或许很难说服自己。
  • 通过 Debug 我们最终的执行因为标记了 Lookup 而走入了 CglibSubclassingInstantiationStrategy.LookupOverrideMethodInterceptor这个方 法的关键实现参考 LookupOverrideMethodInterceptor#intercept
		@Override
		public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp) throws Throwable {
			// Cast is safe, as CallbackFilter filters are used selectively.
			LookupOverride lo = (LookupOverride) getBeanDefinition().getMethodOverrides().getOverride(method);
			Assert.state(lo != null, "LookupOverride not found");
			Object[] argsToUse = (args.length > 0 ? args : null);  // if no-arg, don't insist on args at all
			if (StringUtils.hasText(lo.getBeanName())) {
				return (argsToUse != null ? this.owner.getBean(lo.getBeanName(), argsToUse) :
						this.owner.getBean(lo.getBeanName()));
			}
			else {
				return (argsToUse != null ? this.owner.getBean(method.getReturnType(), argsToUse) :
						this.owner.getBean(method.getReturnType()));
			}
		}
	}
  • 我们的方法调用最终并没有走入案例代码实现的 return null 语句,而是通过 BeanFactory 来获取 Bean。
  • 其实在我们的 getServiceImpl 方法实现中,随便怎么写都行,这不太重要。
  • 为什么我们走入了 CGLIB 搞出的类这是因为我们有方法标记了Lookup。我们可以从下面的这段代码得到验证参考 SimpleInstantiationStrategy#instantiate
@Override
	public Object instantiate(RootBeanDefinition bd, @Nullable String beanName, BeanFactory owner) {
		// Don't override the class with CGLIB if no overrides.
        // 当 hasMethodOverrides 为 true 时,则使用 CGLIB
		if (!bd.hasMethodOverrides()) {
            // ...
			return BeanUtils.instantiateClass(constructorToUse);
		}
		else {
			// Must generate CGLIB subclass.
			return instantiateWithMethodInjection(bd, beanName, owner);
		}
	}
  • 条件的成立在于解析 HelloWorldController 这个 Bean 时,我们会发现有方法标记了 Lookup此时就会添加相应方法到属性 methodOverrides 里面去 此过程由AutowiredAnnotationBeanPostProcessor#determineCandidateConstructors 完成)

2.2 Spring Core - Spring Bean 依赖注入常见错误

  • Spring @Autowired “控制反转、依赖注入”

A. 案例 1过多赠予无所适从

  • @Autowired 时,不管你是菜鸟级还是专家级的 Spring 使用者,都应该制造或者遭遇过类似的错误:

required a single bean, but 2 were found

  • 我们仅需要一个 Bean但实际却提供了 2 个这里的“2”在实际错误中可能是其它大于 1 的任何数字)
  • 案例解析
  • 要找到这个问题的根源,我们就需要对 @Autowired 实现的依赖注入的原理有一定地了解。首先,我们先来了解下 @Autowired 发生的位置和核心过程。
  • 首先,我们先来了解下 @Autowired 发生的位置和核心过程。
  • 当一个 Bean 被构建时,核心包括两个基本步骤:
    • 执行 AbstractAutowireCapableBeanFactory#createBeanInstance 方法:通过构造器反射构造出这个 Bean在相当于构建出 业务Controller 的实例;
    • 执行 AbstractAutowireCapableBeanFactory#populate 方法填充即设置这个Bean相当于设置 Controller 实例中被 @Autowired 标记的 Service 属性成员
  • 在步骤 2 中,“填充”过程的关键就是执行各种 BeanPostProcessor 处理器,关键代码:
	@SuppressWarnings("deprecation")  // for postProcessPropertyValues
	protected void populateBean(String beanName, RootBeanDefinition mbd, @Nullable BeanWrapper bw) {
        // ... 
		if (hasInstAwareBpps) {
			if (pvs == null) {
				pvs = mbd.getPropertyValues();
			}
			for (BeanPostProcessor bp : getBeanPostProcessors()) {
				if (bp instanceof InstantiationAwareBeanPostProcessor) {
					InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp;
					PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName);
                    // ...
				}
			}
		}
        // ...
	}
  • 因为 Controller 含有标记为 Autowired 的成员属性 Service所以会使用到 AutowiredAnnotationBeanPostProcessorBeanPostProcessor 中的一种)来完 成“装配”过程:找出合适的 Service 的 bean 并设置给 Controller#Service。如果深究这个装配过程又可以细分为两个步骤
    • 寻找出所有需要依赖注入的字段和方法,参考 AutowiredAnnotationBeanPostProcessor#postProcessProperties 中的代码行:
    •   InjectionMetadata metadata = findAutowiringMetadata(beanName, bean.getClass(), pvs);
      
    • 根据依赖信息寻找出依赖并完成注入,以字段注入为例,参考 AutowiredFieldElement#inject 方法:
      •       @Override
              protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
                Field field = (Field) this.member;
                Object value;
                if (this.cached) {
                    value = resolvedCachedArgument(beanName, this.cachedFieldValue);
                }
                else {
                    // ...  
                    try {
                        // 寻找“依赖”desc为"自定义Service"的DependencyDescriptor
                        value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
                    }
                // ...   
                if (value != null) {
                    ReflectionUtils.makeAccessible(field);
                    // //装配“依赖”
                    field.set(bean, value);
                }
            }
        }
        
    • DefaultListableBeanFactory#doResolveDependency 中代码片段可以查看实际装配了什么依赖
  • 如果同时满足以下两个条件则会抛出本案例的错误:
    • 调用 determineAutowireCandidate 方法来选出优先级最高的依赖,但是发现并没有优先级可依据。具体选择过程可参考 DefaultListableBeanFactory#determineAutowireCandidate
	@Nullable
	protected String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
		Class<?> requiredType = descriptor.getDependencyType();
        // @Primary
		String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
		if (primaryCandidate != null) {
			return primaryCandidate;
		}
		String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType);
		if (priorityCandidate != null) {
			return priorityCandidate;
		}
		// Fallback
		for (Map.Entry<String, Object> entry : candidates.entrySet()) {
			String candidateName = entry.getKey();
			Object beanInstance = entry.getValue();
			if ((beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) ||
					matchesBeanName(candidateName, descriptor.getDependencyName())) {
				return candidateName;
			}
		}
		return null;
	}
  • 如代码所示,优先级的决策是先根据 @Primary 来决策,其次是 @Priority 决策, 最后是根据 Bean 名字的严格匹配来决策。
  • 如果这些帮助决策优先级的注解都没有被使用,名字也不精确匹配,则返回 null告知无法决策出哪种最合适。
  • @Autowired 要求是必须注入的(即 required 保持默认值为 true或者注解的属性类型并不是可以接受多个 Bean 的类型例如数组、Map、集合。这点可以参考 DefaultListableBeanFactory#indicatesMultipleBeans 的实现:
private boolean indicatesMultipleBeans(Class<?> type) {
    return (type.isArray() || (type.isInterface() &&
        (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFro
}
  • 如果我们把这些条件想得简单点,或许更容易帮助我们去理解这个设计.就像我们遭遇多个无法比较优劣的选择,却必须选择其一时,与其偷偷地随便选择一种,还不如直接报错,起码可以避免更严重的问题发生。
  • 问题修正
  • 找到解决问题的方法:打破上述两个条件中的任何一个即可,即让候选项具有优先级或压根可以不去选择
  • 不过需要你注意的是,不是每一种条件的打破都满足实际需求,例如我们可以通过使用标记 @Primary 的方式来让被标记的候选者有更高优先级,从而避免报错,但是它并不一定符合业务需求,这就好 比我们本身需要两种数据库都能使用,而不是顾此失彼。
@Repository
@Primary
@Slf4j
public class OracleDataService implements DataService{
//省略非关键代码
}
  • 现在,请你仔细研读上述的两个条件,要同时支持多种 DataService且能在不同业务情景下精确匹配到要选择到的 DataService我们可以使用下面的方式去修改
@Autowired
DataService oracleDataService;
  • 如代码所示,修改方式的精髓在于将属性名和 Bean 名字精确匹配,这样就可以让注入选择不犯难:需要 Oracle 时指定属性名为 oracleDataService需要 Cassandra 时则指定属性名为 cassandraDataService
  • PS 这里可以使用注解进行统一使用什么数据库的配置, 也就是自动进行注入使用什么依赖

B. 显式引用 Bean 时首字母忽略大小写

  • 针对案例 1 的问题修正,实际上还存在另外一种常用的解决办法,即采用 @Qualifier 来显式指定引用的是那种服务,例如采用下面的方式:
@Autowired()
@Qualifier("cassandraDataService")
DataService dataService;

这种方式之所以能解决问题,在于它能让寻找出的 Bean 只有一个(即精确匹配),所以压根不会出现后面的决策过程,可以参考 DefaultListableBeanFactory#doResolveDependency

    @Nullable
	public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
			@Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {

            // ...
            // 寻找 Bean 的过程
			Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor);
			if (matchingBeans.isEmpty()) {
				if (isRequired(descriptor)) {
					raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
				}
				return null;
			}

			String autowiredBeanName;
			Object instanceCandidate;

			if (matchingBeans.size() > 1) {
                // 省略多个bean的决策过程
			}
			else {
				// We have exactly one match.
				Map.Entry<String, Object> entry = matchingBeans.entrySet().iterator().next();
				autowiredBeanName = entry.getKey();
				instanceCandidate = entry.getValue();
			}
        // ...
	}
  • 我们会使用 @Qualifier 指定的名称去匹配,最终只找到了唯一一个。[这个可以作为定义Bean的一种规范写在程序中, 最好是定义在一个类文件中,这样就避免了装配问题的发生]
  • 不过在使用 @Qualifier 时,我们有时候会犯另一个经典的小错误,就是我们可能会忽略 Bean 的名称首字母大小写。这里我们把校正后的案例稍稍变形如下:
@Autowired
@Qualifier("CassandraDataService")
DataService dataService;
  • 这样会继续报错

  • 因为: 对于 Bean 的名字,如果没有显式指明,就应该是类名,不过首字母应该小写。

  • PS: 可以配合 Bean的 value和name配置别名但是两个属性不能共存也不能使用特殊符号也不能同时配置多个别名

  • 案例解析

  • 当因为名称问题(例如引用 Bean 首字母搞错了)找不到 Bean 时,会直接抛出 NoSuchBeanDefinitionException。

  • 在这里,我们真正需要关心的问题是:不显式设置名字的 Bean其默认名称首字母到底是大写还是小写呢

  • 当我们启动基于 Spring Boot 的应用程序时会自动扫描我们的Package以找出直接或间接标记了 @Component 的 Bean 的定义即BeanDefinition。 例如 CassandraDataService、SQLiteDataService 都被标记了 @Repository而 Repository 本身被 @Component 标记,所以它们都是间接标记了 @Component。

  • 一旦找出这些 Bean 的信息,就可以生成这些 Bean 的名字然后组合成一个个BeanDefinitionHolder 返回给上层。这个过程关键步骤可以查看下图的代码片段 ClassPathBeanDefinitionScanner#doScan

  • 显式引用Bean时首字母忽略大小写

  • 基本匹配我们前面描述的过程其中方法调用BeanNameGenerator#generateBeanName 即用来产生 Bean 的名字,它有两种实现方式。因为 DataService 的实现都是使用注解标记的, 式。因为 DataService 的实现都是使用注解标记的,的其实是 AnnotationBeanNameGenerator#generateBeanName 这种实现方式,我们可以看下它的具体实现,代码如下:

@Override
public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) {
        if (definition instanceof AnnotatedBeanDefinition) {
            String beanName = determineBeanNameFromAnnotation((AnnotatedBeanDefinition) definition);
            if (StringUtils.hasText(beanName)) {
                // Explicit bean name found.
                return beanName;
            }
        }
        // Fallback: generate a unique default bean name.
        return buildDefaultBeanName(definition, registry);
        }
  • 大体流程只有两步:看 Bean 有没有显式指明名称,如果有则用显式名称,如果没有则产生一个默认名称。
  • 很明显,在我们的案例中,是没有给 Bean 指定名字的,所以产生的 Bean 的名称就是生成的默认名称,查看默认名的产生方法 buildDefaultBeanName其 实现如下:
	protected String buildDefaultBeanName(BeanDefinition definition) {
		String beanClassName = definition.getBeanClassName();
		Assert.state(beanClassName != null, "No bean class name set");
		String shortClassName = ClassUtils.getShortName(beanClassName);
		return Introspector.decapitalize(shortClassName);
	}
  • 首先,获取一个简短的 ClassName然后调用 Introspector#decapitalize 方法,设置首字母大写或小写,具体参考下面的代码实现:
    public static String decapitalize(String name) {
        if (name == null || name.length() == 0) {
            return name;
        }
        if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
                        Character.isUpperCase(name.charAt(0))){
            return name;
        }
        char chars[] = name.toCharArray();
        chars[0] = Character.toLowerCase(chars[0]);
        return new String(chars);
    }
  • 到这,我们很轻松地明白了前面两个问题出现的原因:如果一个类名是以两个大写字母开头的,则首字母不变,其它情况下默认首字母变成小写。
  • 结合我们之前的案例SQLiteDataService 的 Bean其名称应该就是类名本身而 CassandraDataService 的Bean 名称则变成了首字母小写cassandraDataService
  • 问题修正
  • 方法1 : 引用处纠正首字母大小写问题:
@Autowired
@Qualifier("cassandraDataService")
DataService dataService;
  • 方法2 : 定义处显式指定 Bean 名字我们可以保持引用代码不变而通过显式指明CassandraDataService 的 Bean 名称为 CassandraDataService 来纠正这个问题
@Repository("CassandraDataService")
@Slf4j
public class CassandraDataService implements DataService {
//省略实现
}
  • 我们的程序就可以精确匹配到要找的 Bean 了。比较一下这两种修改方法的话,如果你不太了解源码,不想纠结于首字母到底是大写还是小写,建议你用第二种方法去避免困扰。

C. 引用内部类的 Bean 遗忘类名

  • 我们需要定义一个内部类来实现一种新的 DataService代码如下
public class StudentController {
    @Repository
    public static class InnerClassDataService implements DataService{
      @Override
      public void deleteStudent(int id) {
        //空实现
      }
    }
    //省略其他非关键代码
}

  • 遇到这种情况,我们一般都会很自然地用下面的方式直接去显式引用这个 Bean
@Autowired
@Qualifier("innerClassDataService")
DataService innerClassDataService;
  • 很明显,有了案例 2 的经验,我们上来就直接采用了首字母小写以避免案例 2 中的错误 但这样的代码是不是就没问题了呢?实际上,仍然会报错“找不到 Bean”这是为什么

  • 案例解析

  • 实际上,我们遭遇的情况是“如何引用内部类的 Bean”。解析案例 2 的时候,我曾经贴出了如何产生默认 Bean 名的方法即AnnotationBeanNameGenerator#buildDefaultBeanName 当时我们只关注了首字母是否小写的代码片段,而在最后变换首字母之前,有一行语句是对 class 名字的处理,代码如下:

String shortClassName = ClassUtils.getShortName(beanClassName);
  • 我们可以看下它的实现,参考 ClassUtils#getShortName 方法:
	public static String getShortName(String className) {
		Assert.hasLength(className, "Class name must not be empty");
		int lastDotIndex = className.lastIndexOf(PACKAGE_SEPARATOR);
		int nameEndIndex = className.indexOf(CGLIB_CLASS_SEPARATOR);
		if (nameEndIndex == -1) {
			nameEndIndex = className.length();
		}
		String shortName = className.substring(lastDotIndex + 1, nameEndIndex);
		shortName = shortName.replace(INNER_CLASS_SEPARATOR, PACKAGE_SEPARATOR);
		return shortName;
	}
  • 很明显,假设我们是一个内部类,例如下面的类名:
  • com.spring.puzzle.class2.example3.StudentController.InnerClassDataService
  • 在经过这个方法的处理后,我们得到的其实是下面这个名称:
  • StudentController.InnerClassDataService
  • 最后经过 Introspector.decapitalize 的首字母变换,最终获取的 Bean 名称如下:
  • studentController.InnerClassDataService
  • 所以我们在案例程序中,直接使用 innerClassDataService 自然找不到想要的 Bean
  • 问题修正
  • 上面源码的跟踪结果显示, 通过下面的方法进行修复
@Autowired
@Qualifier("studentController.InnerClassDataService")
DataService innerClassDataService;
  • 这个引用看起来有些许奇怪但实际上是可以工作的反而直接使用innerClassDataService 来引用倒是真的不可行。
  • 对源码的学习是否全面决定了我们以后犯错的可能性大小

D. @Value 没有注入预期的值

  • @Value 必须指定一个字符串值,因为其定义做了要求,定义代码如下:
public @interface Value {
    /**
    * The actual value expression &mdash; for example, <code>#{systemPropertie
    */
    String value();
}
  • 我们一般都会因为 @Value 常用于 String 类型的装配而误以为 @Value 不能用于非内置对象的装配,实际上这是一个常见的误区
  • 例如,我们可以使用下面这种方式来 Autowired 一个属性成员:
@Value("#{student}")
private Student student;
  • 其中 student 这个 Bean 定义如下:
@Bean
public Student student(){
    Student student = createStudent(1, "xie");
    return student;
}
  • 使用 @Value 更多是用来装配 String而且它支持多种强大的装配方式典型的方式参考下面的示例
//注册正常字符串
@Value("我是字符串")
private String text;

//注入系统参数、环境变量或者配置文件中的值
@Value("${ip}")
private String ip

//注入其他Bean属性其中student为bean的IDname为其属性
@Value("#{student.name}")
private String name;
  • 异常案例
  • application.properties
username=admin
password=pass
@RestController
@Slf4j
public class ValueTestController {
    @Value("${username}")
    private String username;
    @Value("${password}")
    private String password;
    
    @RequestMapping(path = "user", method = RequestMethod.GET)
    public String getUser(){
        return username + ":" + password;
    }
}
  • 我们去打印上述代码中的 username 和 password 时,我们会发现 password 正确返回了,但是 username 返回的并不是配置文件中指明的 admin
  • 案例解析
  • Spring 是如何根据 @Value 来查询“值”的
  • 通过方法 DefaultListableBeanFactory#doResolveDependency 来了解 @Value 的核心工作流程,代码如下:
@Nullable
public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
@Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {

        // ... 

        Class<?> type = descriptor.getDependencyType();
        // 寻找 @Value
        Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
        if (value != null) {
            if (value instanceof String) {
                // 解析 @Value
                String strVal = resolveEmbeddedValue((String) value);
                BeanDefinition bd = (beanName != null && containsBean(beanName) ?
                    getMergedBeanDefinition(beanName) : null);
                value = evaluateBeanDefinitionString(strVal, bd);
        }
        
        // 转化 Value 解析的结果到装配的类型    
        TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
        try {
            return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
        }
        catch (UnsupportedOperationException ex) {
            // 异常处理
            }
        }

        // ... 
        }
  • @Value 的工作大体分为以下三个核心步骤。
  • 寻找 @Value,在这步中,主要是判断这个属性字段是否标记为 @Value
  • 依据的方法参考 QualifierAnnotationAutowireCandidateResolver#findValue
	@Nullable
	protected Object findValue(Annotation[] annotationsToSearch) {
		if (annotationsToSearch.length > 0) {   // qualifier annotations have to be local
			AnnotationAttributes attr = AnnotatedElementUtils.getMergedAnnotationAttributes(
					AnnotatedElementUtils.forAnnotations(annotationsToSearch), this.valueAnnotationType);
            //valueAnnotationType即为@Value
            if (attr != null) {
				return extractValue(attr);
			}
		}
		return null;
	}
  • 解析 @Value 的字符串值

  • 如果一个字段标记了 @Value则可以拿到对应的字符串值然后就可以根据字符串值去做解析最终解析的结果可能是一个字符串也可能是一个对象这取决于字符串怎么写。

  • 将解析结果转化为要装配的对象的类型

  • 当拿到第二步生成的结果后,我们会发现可能和我们要装配的类型不匹配。假设我们定义的是 UUID而我们获取的结果是一个字符串那么这个时候就会根据目标类型来寻找转 化器执行转化,字符串到 UUID 的转化实际上发生在 UUIDEditor 中:

public class UUIDEditor extends PropertyEditorSupport {
    @Override
    public void setAsText(String text) throws IllegalArgumentException
    if (StringUtils.hasText(text)) {
        //转化操作
        setValue(UUID.fromString(text.trim()));
    }else {
        setValue(null);
    }
}
    //...
}
  • 解析 Value 指定字符串过程
String strVal = resolveEmbeddedValue((String) value);
  • 这里其实是在解析嵌入的值实际上就是“替换占位符”工作。具体而言它采用的是PropertySourcesPlaceholderConfigurer 根据 PropertySources 来替换。

  • 不过当使用${username} 来获取替换值时,其最终执行的查找并不是局限在 application.property 文件中的。

  • 而具体的查找执行,我们可以通过下面的代码 PropertySourcesPropertyResolver#getProperty来获取它的执行方式

    @Nullable
	protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
		if (this.propertySources != null) {
			for (PropertySource<?> propertySource : this.propertySources) {
				if (logger.isTraceEnabled()) {
					logger.trace("Searching for key '" + key + "' in PropertySource '" +
							propertySource.getName() + "'");
				}
				Object value = propertySource.getProperty(key);
				if (value != null) {
					if (resolveNestedPlaceholders && value instanceof String) {
						value = resolveNestedPlaceholders((String) value);
					}
					logKeyFound(key, propertySource, value);
                    // 查到 Value 就退出
					return convertValueIfNecessary(value, targetValueType);
				}
			}
		}
		if (logger.isTraceEnabled()) {
			logger.trace("Could not find key '" + key + "' in any property source");
		}
		return null;
	}
  • 在解析 Value 字符串时其实是有顺序的查找的源是存在CopyOnWriteArrayList 中,在启动时就被有序固定下来),一个一个“源”执行查找。在其中一个源找到后,就可以直接返回了。

  • 查看 systemEnvironment 这个源,会发现刚好有一个 username 和我们是重合的,且值不是 pass。

  • 刚好系统环境变量systemEnvironment中含有同名的配置。实际上对于系统参数systemProperties也是一样的这些参数或者变量都有很多如果我们没有意识到它 的存在,起了一个同名的字符串作为 @Value 的值,则很容易引发这类问题。

  • 问题修正

  • 一定要注意不仅要避免和环境变量冲突,也要注意避免和系统变量等其他变量冲突

E. 错乱的注入集合

  • 集合类型的自动注入是 Spring 提供的另外一个强大功能。
  • 存在多个学生 Bean我们需要找出来并存储到一个 List里面去。多个学生 Bean 的定义如下:
@Bean
public Student student1(){
    return createStudent(1, "xie");
} 

@Bean
public Student student2(){
    return createStudent(2, "fang");
} 
private Student createStudent(int id, String name) {
    Student student = new Student();
    student.setId(id);
    student.setName(name);
    return student;
}
  • 有了集合类型的自动注入后,我们就可以把零散的学生 Bean 收集起来了,代码示例如
@RestController
@Slf4j
public class StudentController {
private List<Student> students;
public StudentController(List<Student> students){
    this.students = students;
} 
@RequestMapping(path = "students", method = RequestMethod.GET)
public String listStudents(){
    return students.toString();
}
}
  • 通过上述代码,我们就可以完成集合类型的注入工作,输出结果如下:

[Student(id=1, name=xie), Student(id=2, name=fang)]

  • 当我们持续增加一些 student 时,可能就不喜欢用这种方式来注入集合类型了,而是倾向于用下面的方式去完成注入工作:
@Bean
public List<Student> students(){
    Student student3 = createStudent(3, "liu");
    Student student4 = createStudent(4, "fu");
    return Arrays.asList(student3, student4);
}
  • 我们不妨将上面这种方式命名为“直接装配方式”,而将之前的那种命名为“收集方式”。

  • 如果我们不小心让这 2 种方式同时存在了,结果会怎样?

  • 然而,当我们运行起程序,就会发现后面的注入方式根本没有生效。即依然返回的是前面定义的 2 个学生。为什么会出现这样的错误呢?

  • 案例解析

  • 收集装配风格Spring 使用的是 DefaultListableBeanFactory#resolveMultipleBeans

@Nullable
	private Object resolveMultipleBeans(DependencyDescriptor descriptor, @Nullable String beanName,
			@Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) {

		Class<?> type = descriptor.getDependencyType();

		if (descriptor instanceof StreamDependencyDescriptor) {
            // 装配 stream
			Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor);
			if (autowiredBeanNames != null) {
				autowiredBeanNames.addAll(matchingBeans.keySet());
			}
			Stream<Object> stream = matchingBeans.keySet().stream()
					.map(name -> descriptor.resolveCandidate(name, type, this))
					.filter(bean -> !(bean instanceof NullBean));
			if (((StreamDependencyDescriptor) descriptor).isOrdered()) {
				stream = stream.sorted(adaptOrderComparator(matchingBeans));
			}
			return stream;
		}
		else if (type.isArray()) {
            // 装配 数组
			Class<?> componentType = type.getComponentType();
			ResolvableType resolvableType = descriptor.getResolvableType();
			Class<?> resolvedArrayType = resolvableType.resolve(type);
			if (resolvedArrayType != type) {
				componentType = resolvableType.getComponentType().resolve();
			}
			if (componentType == null) {
				return null;
			}
			Map<String, Object> matchingBeans = findAutowireCandidates(beanName, componentType,
					new MultiElementDescriptor(descriptor));
			if (matchingBeans.isEmpty()) {
				return null;
			}
			if (autowiredBeanNames != null) {
				autowiredBeanNames.addAll(matchingBeans.keySet());
			}
			TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
			Object result = converter.convertIfNecessary(matchingBeans.values(), resolvedArrayType);
			if (result instanceof Object[]) {
				Comparator<Object> comparator = adaptDependencyComparator(matchingBeans);
				if (comparator != null) {
					Arrays.sort((Object[]) result, comparator);
				}
			}
			return result;
		}
		else if (Collection.class.isAssignableFrom(type) && type.isInterface()) {
            //装配集合
            //获取集合的元素类型
			Class<?> elementType = descriptor.getResolvableType().asCollection().resolveGeneric();
			if (elementType == null) {
				return null;
			}
            // 根据元素类型查找所有的bean
			Map<String, Object> matchingBeans = findAutowireCandidates(beanName, elementType,
					new MultiElementDescriptor(descriptor));
			if (matchingBeans.isEmpty()) {
				return null;
			}
			if (autowiredBeanNames != null) {
				autowiredBeanNames.addAll(matchingBeans.keySet());
			}
            // 转化查到的所有bean放置到集合并返回
			TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
			Object result = converter.convertIfNecessary(matchingBeans.values(), type);
            // ...
			return result;
		}
		else if (Map.class == type) {
            // 解析map
            // ...
			return matchingBeans;
		}
		else {
			return null;
		}
	}
  • 到这,我们就不难概括出这种收集式集合装配方式的大体过程了

  • 1- 获取集合类型的元素类型

    • 目标类型定义为 List students所以元素类型为 Student获取的具体方法参考代码行
    • Class elementType = descriptor.getResolvableType().asCollection().resolveGeneric();
  • 2- 根据元素类型,找出所有的 Bean

    • 有了上面的元素类型,即可根据元素类型来找出所有的 Bean关键代码行如下
    • Map<String, Object> matchingBeans = findAutowireCandidates(beanName,elementType, new MultiElementDescriptor(descriptor));
  • 3- 将匹配的所有的 Bean 按目标类型进行转化

    • 经过步骤 2我们获取的所有的 Bean 都是以 java.util.LinkedHashMap.LinkedValues 形式存储的,和我们的目标类型大概率不同,所以最后一步需要做的是按需转化。
    • 中,我们就需要把它转化为 List转化的关键代码如下:
    • Object result = converter.convertIfNecessary(matchingBeans.values(), type);
  • 两种装配集合的方式是不能同存的,结合本案例,当使用收集装配方式来装配时,能找到任何一个对应的 Bean则返回如果一个都没有找到才会采用直接装配的方式。

  • 问题修复

  • 在对于同一个集合对象的注入上,混合多种注入方式是不可取的,这样除了错乱,别无所得。

2.3 Spring Core - Spring Bean生命周期常见错误

A. 构造器内抛空指针异常

  • 在构建宿舍管理系统时,有 LightMgrService 来管理 LightService从而控制宿舍灯的开启和关闭。我们希望在 LightMgrService 初始化时能够自动调用 LightService 的 check 方法来检查所有宿舍灯的电路是否正常,代码如下:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class LightMgrService { 
@Autowired
private LightService lightService;

public LightMgrService() {
    lightService.check();
}
}
  • 我们在 LightMgrService 的默认构造器中调用了通过 @Autoware 注入的成员变量
  • LightService 的 check 方法:
@Service
public class LightService {
  public void start() {
    System.out.println("turn on all lights");
  }
  public void shutdown() {
    System.out.println("turn off all lights");
  }
  public void check() {
    System.out.println("check all lights");
  }
}
  • 以上代码定义了 LightService 对象的原始类。

  • 从整个案例代码实现来看,我们的期待是在 LightMgrService 初始化过程中LightService 因为标记为 @Autowired所以能被自动装配好然后在 LightMgrService 的构造器执行中LightService 的 shutdown() 方法能被自动调用;最终打印出 check all lights。

  • 然而事与愿违,我们得到的只会是 NullPointerException

  • 案例解析

  • Spring 类初始化过程没有足够的了解

  • Spring类初始化

  • 这个图初看起来复杂,我们不妨将其分为三部分:

    • 第一部分,将一些必要的系统类,比如 Bean 的后置处理器类,注册到 Spring 容器,其中就包括我们这节课关注的 CommonAnnotationBeanPostProcessor 类;
    • 第二部分,将这些后置处理器实例化,并注册到 Spring 的容器中;
    • 第三部分,实例化所有用户定制类,调用后置处理器进行辅助装配、类初始化等等。
  • 第一部分和第二部分并非是我们今天要讨论的重点这里仅仅是为了让你知道CommonAnnotationBeanPostProcessor 这个后置处理类是何时被 Spring 加载和实例化的。

  • 拓展两个知识点:

    • 很多必要的系统类,尤其是 Bean 后置处理器比如CommonAnnotationBeanPostProcessor、AutowiredAnnotationBeanPostProcessor 等),都是被 Spring 统一加载和管理的, 并在 Spring 中扮演了非常重要的角色;
    • 通过 Bean 后置处理器Spring 能够非常灵活地在不同的场景调用不同的后置处理器,比如接下来我会讲到示例问题如何修正,修正方案中提到的 PostConstruct 注解,它的处理逻辑就需要用到 CommonAnnotationBeanPostProcessor继承自InitDestroyAnnotationBeanPostProcessor这个后置处理器。
  • 我们重点看下第三部分,即 Spring 初始化单例类的一般过程,基本都是 getBean()->doGetBean()->getSingleton(),如果发现 Bean 不存在,则调用 createBean()- doCreateBean() 进行实例化

  • 查看 doCreateBean() 的源代码如下:

  • Bean 初始化的三个关键步骤

    • createBeanInstance 实例化 Bean
    • populateBean 注入 Bean 依赖
    • initializeBean 初始化 Bean
  • 执行 @PostConstruct 标记的方法 这三个功能,这也和上述时序图的流程相符。

  • 而用来实例化 Bean 的 createBeanInstance 方法通过依次调用 DefaultListableBeanFactory.instantiateBean() > SimpleInstantiationStrategy.instantiate(),最终执行到 BeanUtils.instantiateClass()

  • 这里因为当前的语言并非 Kotlin所以最终将调用 ctor.newInstance() 方法实例化用户定制类 LightMgrService而默认构造器显然是在类实例化的时候被自动调用的Spring 也 无法控制。而此时负责自动装配的 populateBean 方法还没有被执行LightMgrService 的属性 LightService 还是 null因而得到空指针异常也在情理之中。

  • 问题修正

  • 问题根源: 使用 @Autowired 直接标记在成员属性上而引发的装配行为是发生在构造器执行之后的

  • 修正代码1

@Component
public class LightMgrService {
  private LightService lightService;

  public LightMgrService(LightService lightService) {
    this.lightService = lightService;
    lightService.check();
  }
}
  • 当使用上面的代码时,构造器参数 LightService 会被自动注入 LightService 的 Bean从而在构造器执行时不会出现空指针。可以说使用构造器参数来隐式注入是一种 Spring 最佳实践

  • 修正代码2

  • 添加 init 方法,并且使用 PostConstruct 注解进行修饰:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class LightMgrService {
  @Autowired
  private LightService lightService;

  @PostConstruct
  public void init() {
    lightService.check();
  }
}
    • 修正代码3
  • 实现 InitializingBean 接口,在其 afterPropertiesSet() 方法中执行初始化代码:
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class LightMgrService implements InitializingBean {
  @Autowired
  private LightService lightService;
  
  @Override
  public void afterPropertiesSet() throws Exception {
    lightService.check();
  }
}
  • 后续的两种方案并不是最优的,但是在一些场景下,这两种方案各有所长

B. 意外触发 shutdown 方法

  • 在类销毁时,也会有一些相对隐蔽的约定,导致一些难以察觉的错误
  • 我们可能会去掉 @Service 注解,而是使用另外一种产生 Bean 的方式:创建一个配置类 BeanConfiguration标记 @Configuration来创建一堆 Bean其中就 包含了创建 LightService 类型的 Bean并将其注册到 Spring 容器:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BeanConfiguration {
    @Bean
    public LightService getTransmission(){
        return new LightService();
    }
}
  • 复用案例 1 的启动程序,稍作修改,让 Spring 启动完成后立马关闭当前 Spring 上下文这样等同于模拟宿舍管理系统的启停:
@SpringBootApplication
public class Application {
public static void main(String[] args) {
    ConfigurableApplicationContext context = SpringApplication.run(Applica
    context.close();
}
}
  • 以上代码没有其他任何方法的调用,仅仅是将所有符合约定的类初始化并加载到 Spring 容器,完成后再关闭当前的 Spring 容器。

  • 按照预期,这段代码运行后不会有任何的 log 输出,毕竟我们只是改变了 Bean 的产生方式。

  • 但实际运行这段代码后,我们可以看到控制台上打印了 shutting down all lights。显然shutdown 方法未按照预期被执行了,这导致一个很有意思的 bug在使用新的 Bean 生 成方式之前,每一次宿舍管理服务被重启时,宿舍里所有的灯都不会被关闭。但是修改后,只有服务重启,灯都被意外关闭了。如何理解这个 bug?

  • 问题解析

  • TODO 后续补充源码跟进

  • 问题修正

  • 我们可以通过避免在 Java 类中定义一些带有特殊意义动词的方法来解决,当然如果一定要定义名为 close 或者 shutdown 方法,也可以通过将 Bean 注解内 destroyMethod 属性 设置为空的方式来解决这个问题。

  • 第一种修改方式比较简单,所以这里只展示第二种修改方式,代码如下:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BeanConfiguration {

    @Bean(destroyMethod="")
    public LightService getTransmission(){
        return new LightService();
    }
}

3. 业务编写之坑

3.1

3.21 设计 - 代码重复:搞定代码重复的三个绝招

  • 涉及知识: 设计模式、Java 高级特性、OOP

A. 利用工厂模式 + 模板方法模式,消除 if…else 和重复代码

  • 业务模拟背景
    • 假设要开发一个购物车下单的功能,针对不同用户进行不同处理:
      • 普通用户需要收取运费,运费是商品价格的 10%,无商品折扣;
      • VIP 用户同样需要收取商品价格 10% 的快递费,但购买两件以上相同商品时,第三件开始享受一定折扣;
      • 内部用户可以免运费,无商品折扣
    • 目标
      • 实现三种类型的购物车业务逻辑,把入参 Map 对象Key 是商品 IDValue是商品数量转换为出参购物车类型 Cart。
//购物车
@Data
public class Cart {
    //商品清单
    private List<Item> items = new ArrayList<>();
    //总优惠
    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<Long, Integer> items) {
    Cart cart = new Cart();
    //把Map的购物车转换为Item列表
    List<Item> itemList = new ArrayList<>();
    items.entrySet().stream().forEach(entry -> {
      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 -> {
      //运费为商品总价的10%
      item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(i
      //无优惠
      item.setCouponPrice(BigDecimal.ZERO);
    });

    //计算商品总价
    cart.setTotalItemPrice(cart.getItems().stream().map(item -> 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<Long, Integer> items) {
    ...
    itemList.stream().forEach(item -> {
    //运费为商品总价的10%
    item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(i))
    //购买两件以上相同商品,第三件开始享受一定折扣
    if (item.getQuantity() > 2) {
        item.setCouponPrice(item.getPrice()
            .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPerce))
            .multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
    } else {
        item.setCouponPrice(BigDecimal.ZERO);
    }
    });
    ...
    return cart;
    }
}
  • 最后是免运费、无折扣的内部用户,同样只是处理商品折扣和运费时的逻辑差异:
public class InternalUserCart {
public Cart process(long userId, Map<Long, Integer> items) {
    ...
    itemList.stream().forEach(item -> {
    //免运费
    item.setDeliveryPrice(BigDecimal.ZERO);
    //无优惠
    item.setCouponPrice(BigDecimal.ZERO);
    });
    ...
  return cart;
}
}
  • 分析上述坏代码:
  • 对比一下代码量可以发现,三种购物车 70% 的代码是重复的。原因很简单,虽然不同类型用户计算运费和优惠的方式不同,但整个购物车的初始化、统计总价、总运费、总优惠和支 付价格的逻辑都是一样的。
  • 代码重复本身不可怕,可怕的是漏改或改错。比如,写 VIP 用户购物车的同学发现商品总价计算有 Bug不应该是把所有 Item 的 price 加在一起,而是应该把所有 Item 的 price*quantity 加在一起。这时,他可能会只修改 VIP 用户购物车的代码,而忽略了普通用户、内部用户的购物车中,重复的逻辑实现也有相同的 Bug
  • 有了三个购物车后,我们就需要根据不同的用户类型使用不同的购物车了。如下代码所示,使用三个 if 实现不同类型用户调用不同购物车的 process 方法:
@GetMapping("wrong")
public Cart wrong(@RequestParam("userId") int userId) {
//根据用户ID获得用户类型
String userCategory = Db.getUserCategory(userId);
//普通用户处理逻辑
if (userCategory.equals("Normal")) {
    NormalUserCart normalUserCart = new NormalUserCart();
    return normalUserCart.process(userId, items);
}
//VIP用户处理逻辑
if (userCategory.equals("Vip")) {
    VipUserCart vipUserCart = new VipUserCart();
    return vipUserCart.process(userId, items);
}
//内部用户处理逻辑
if (userCategory.equals("Internal")) {
    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<Long, Integer> items) {
    Cart cart = new Cart();
    List<Item> itemList = new ArrayList<>();
    items.entrySet().stream().forEach(entry -> {
      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 -> {
      processCouponPrice(userId, item);
      processDeliveryPrice(userId, item);
    });
    //计算商品总价
    cart.setTotalItemPrice(cart.getItems().stream().map(item -> 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 = "NormalUserCart")
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("0.1")));
    }
}
  • VIP 用户的购物车 VipUserCart直接继承了 NormalUserCart只需要修改多买优惠策略
@Service(value = "VipUserCart")
public class VipUserCart extends NormalUserCart {
  @Override
  protected void processCouponPrice(long userId, Item item) {
    if (item.getQuantity() > 2) {
      item.setCouponPrice(item.getPrice()
              .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100")))
              .multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
    }else {
      item.setCouponPrice(BigDecimal.ZERO);
    }
  }
}
  • 内部用户购物车 InternalUserCart 是最简单的,直接设置 0 运费和 0 折扣即可:
@Service(value = "InternalUserCart")
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);
  }
}
  • 接下来,我们再看看如何能避免三个 if 逻辑
  • 定义三个购物车子类时,我们在 @Service 注解中对 Bean 进行了命名。既然三个购物车都叫 XXXUserCart那我们就可以把用户类型字符串拼接 UserCart 构成购物车 Bean 的名称,然后利用 Spring 的 IoC 容器,通过 Bean 的名称直接获取到AbstractCart调用其 process 方法即可实现通用。 这就是工厂模式,只不过是借助 Spring 容器实现罢了:
@GetMapping("right")
public Cart right(@RequestParam("userId") int userId) {
        String userCategory = Db.getUserCategory(userId);
        AbstractCart cart = (AbstractCart) applicationContext.getBean(userCategory + "UserCart");
        return cart.process(userId, items);  
}
  • 之后如果有了新的用户类型、新的用户逻辑,完全不用对代码做任何修改,只要新增一个 XXXUserCart 类继承 AbstractCart实现特殊的优惠和运费处理逻辑就可以了
  • 我们就利用工厂模式 + 模板方法模式,不仅消除了重复代码,还避免了修改既有代码的风险
  • 这就是设计模式中的开闭原则:对修改关闭,对扩展开放

B. 利用注解 + 反射消除重复代码

  • 再看一个三方接口的调用案例,同样也是一个普通的业务逻辑
  • 假设银行提供了一些 API 接口,对参数的序列化有点特殊,不使用 JSON而是需要我们把参数依次拼在一起构成一个大字符串
  • 按照银行提供的 API 文档的顺序,把所有参数构成定长的数据,然后拼接在一起作为整个字符串。
  • 因为每一种参数都有固定长度,未达到长度时需要做填充处理:
    • 字符串类型的参数不满长度部分需要以下划线右填充,也就是字符串内容靠左;
    • 数字类型的参数不满长度部分以 0 左填充,也就是实际数字靠右;
    • 货币类型的表示需要把金额向下舍入 2 位到分,以分为单位,作为数字类型同样进行左填充
  • 对所有参数做 MD5 操作作为签名为了方便理解Demo 中不涉及加盐处理)。
  • 代码很容易实现,直接根据接口定义实现填充操作、加签名、请求调用操作即可:
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("%-10s", name).replace(' ', '_'));
      //字符串靠左多余的地方填充_
      stringBuilder.append(String.format("%-18s", identity).replace(' ', '_'));
      //数字靠右多余的地方用0填充
      stringBuilder.append(String.format("%05d", age));
      // 字符串靠左多余的地方用_填充
      stringBuilder.append(String.format("%-11s", mobile).replace(' ', '_'));
      // 最后加上MD5作为签名
      stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
      return Request.Post("http://localhost:45678/reflection/bank/createUser")
              .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("%020d", userId));
    //金额向下舍入2位到分以分为单位作为数字靠右多余的地方用0填充
    stringBuilder.append(String.format("%010d", amount.setScale(2, , RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
    //最后加上MD5作为签名
    stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
    return Request.Post("http://localhost:45678/reflection/bank/pay")
            .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 "";
    String url() default "";
}
  • 然后,我们再定义一个自定义注解 @BankAPIField用于描述接口的每一个字段规范包含参数的次序、类型和长度三个属性
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
@Inherited
public @interface BankAPIField {
  int order() default -1;
  int length() default -1;
  String type() default "";
}
  • 接下来,注解就可以发挥威力了。
  • 如下所示,我们定义了 CreateUserAPI 类描述创建用户接口的信息,通过为接口增加 @BankAPI 注解,来补充接口的 URL 和描述等元数据;通过为每一个字段增加 @BankAPIField 注解,来补充参数的顺序、类型和长度等元数据:
@BankAPI(url = "/bank/createUser", desc = "创建用户接口")
@Data
public class CreateUserAPI extends AbstractAPI {
  @BankAPIField(order = 1, type = "S", length = 10)
  private String name;
  @BankAPIField(order = 2, type = "S", length = 18)
  private String identity;
  @BankAPIField(order = 4, type = "S", length = 11) // 注意这里的order需要按照API
  private String mobile;
  @BankAPIField(order = 3, type = "N", length = 5)
  private int age;
}
  • 另一个 PayAPI 类也是类似的实现:
@BankAPI(url = "/bank/pay", desc = "支付接口")
@Data
public class PayAPI extends AbstractAPI {
  @BankAPIField(order = 1, type = "N", length = 20)
  private long userId;
  @BankAPIField(order = 2, type = "M", length = 10)
  private BigDecimal amount;
}
  • 这 2 个类继承的 AbstractAPI 类是一个空实现,因为这个案例中的接口并没有公共数据可以抽象放到基类。

  • 通过这 2 个类,我们可以在几秒钟内完成和 API 清单表格的核对。理论上,如果我们的核心翻译过程(也就是把注解和接口 API 序列化为请求需要的字符串的过程)没问题,只要 注解和表格一致API 请求的翻译就不会有任何问题。

  • 以上,我们通过注解实现了对 API 参数的描述。接下来,我们再看看反射如何配合注解实现动态的接口参数组装:

    private static String remoteCall(AbstractAPI api) throws IOException {
        //从BankAPI注解获取请求地址, 我们从类上获得了 BankAPI 注解,然后拿到其 URL 属性,后续进行远程调用。
        BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
        bankAPI.url();
        StringBuilder stringBuilder = new StringBuilder();
        // 使用 stream 快速实现了获取类中所有带 BankAPIField 注解的字段,并把字段按 order 属性排序,然后设置私有字段反射可访问。
        Arrays.stream(api.getClass().getDeclaredFields()) //获得所有字段
            .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段
            .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order()))//根据注解中的order对字段排序
            .peek(field -> field.setAccessible(true))//设置可以访问私有字段
            // 实现了反射获取注解的值,然后根据 BankAPIField 拿到的参数类型,按照三种标准进行格式化,将所有参数的格式化逻辑集中在了这一处。
            .forEach(field -> {
                //获得注解
                BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class);
                Object value = "";
                try {
                    //反射获取字段值
                    value = field.get(api);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
                // 根据字段类型以正确的填充方式格式化字符串
                switch (bankAPIField.type()) {
                    case "S": {
                        stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ','_'));
                        break;
                    }
                    case "N":{
                        stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ','0'));
                    }
                    case "M":{
                        if (!(value instanceof BigDecimal))
                            throw new  RuntimeException(String.format("{} 的 {} 必须是BigDecimal", api, field));
                            stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
                            break;
                    }
                    default:
                        break;
                }
            });
        //签名逻辑
        stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
        String param = stringBuilder.toString();
        long begin = System.currentTimeMillis();
        //发请求
        String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url())
                        .bodyString(param, ContentType.APPLICATION_JSON)
                        .execute().returnContent().asString();
        log.info("调用银行API {} url:{} 参数:{} 耗时:{}ms", 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);
}
  • 其实,许多涉及类结构性的通用处理,都可以按照这个模式来减少重复代码。反射给予了我们在不知晓类结构的时候,按照固定的逻辑处理类的成员;而注解给了我们为这些成员补充 元数据的能力,使得我们利用反射实现通用逻辑的时候,可以从外部获得更多我们关心的数据。

C. 利用属性拷贝工具消除重复代码

  • 业务代码中经常出现的代码逻辑,实体之间的转换复制
  • 对于三层架构的系统,考虑到层之间的解耦隔离以及每一层对数据的不同需求,通常每一层都会有自己的 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());
  • 修改方法很简单,可以使用类似 BeanUtils 这种 Mapping 工具来做 Bean 的转换copyProperties 方法还允许我们提供需要忽略的属性:
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
BeanUtils.copyProperties(orderDTO, orderDO, "id");
return orderDO;

D. 总结

  • 观察者模式适合所有发布-订阅类型的场景可以是同步阻塞也可以是异步非阻塞的可以是进程内的也可以是系统间的解耦。工作中用的多的是Guava的EventBus。
  • cglib中BeanCopier也提供了mapping功能基于动态代理实现
  • 属性拷贝工具相较于直接写get和set会有性能上的差异
    • 会有 不过一般不会存在性能瓶颈
  • 策略 状态机 职责链模式在开发中的使用也比较常见
  • TODO 借助这个话题来讨论设计模式在实际开发中的使用, 来优化我们的代码设计

3.22 设计 - 接口设计:系统间对话的语言,一定要统一

3.23 设计 - 缓存设计:缓存可以锦上添花也可以落井下石

3.24 设计 - 业务代码写完,就意味着生产就绪了?

3.25 设计 - 异步处理好用,但非常容易用错

  • 异步处理是互联网应用不可或缺的一种架构模式,大多数业务项目都是由同步处理、异步处理和定时任务处理三种模式相辅相成实现的。
  • 区别于同步处理,异步处理无需同步等待流程处理完毕,因此适用场景主要包括:
  • 服务于主流程的分支流程。
    • 比如,在注册流程中,把数据写入数据库的操作是主流程但注册后给用户发优惠券或欢迎短信的操作是分支流程,时效性不那么强,可以进行异步处理。
    • 用户不需要实时看到结果的流程。比如,下单后的配货、送货流程完全可以进行异步处理,每个阶段处理完成后,再给用户发推送或短信让用户知晓即可
  • 异步处理因为可以有 MQ 中间件的介入用于任务的缓冲的分发,所以相比于同步处理,在应对流量洪峰、实现模块解耦和消息广播方面有功能优势。
  • 不过,异步处理虽然好用,但在实现的时候却有三个最容易犯的错
    • 异步处理流程的可靠性问题、消息发送模式的区分问题,以及大量死信消息堵塞队列的问题

A. 异步处理需要消息补偿闭环

  • 使用类似 RabbitMQ、RocketMQ 等 MQ 系统来做消息队列实现异步处理,虽然说消息可以落地到磁盘保存,即使 MQ 出现问题消息数据也不会丢失,但是异步流程在消息发送、 传输、处理等环节,都可能发生消息丢失。此外,任何 MQ 中间件都无法确保 100% 可用,需要考虑不可用时异步流程如何继续进行。
  • 对于异步处理流程,必须考虑补偿或者说建立主备双活流程
  • 我们来看一个用户注册后异步发送欢迎消息的场景。用户注册落数据库的流程为同步流程,会员服务收到消息后发送欢迎消息的流程为异步流程。
  • 异步消息补偿闭环
  • 分析一下:
    • 蓝色的线,使用 MQ 进行的异步处理,我们称作主线,可能存在消息丢失的情况(虚线代表异步调用);
    • 绿色的线,使用补偿 Job 定期进行消息补偿,我们称作备线,用来补偿主线丢失的消息;
    • 考虑到极端的 MQ 中间件失效的情况,我们要求备线的处理吞吐能力达到主线的能力水平
  • 相关的代码实现
  • 代码示例:
  • 首先,定义 UserController 用于注册 + 发送异步消息。对于注册方法,我们一次性注册 10 个用户,用户注册消息不能发送出去的概率为 50%。
    • 参考代码: com.baiye.demo.case25.compensation.UserController
  • 然后,定义 MemberService 类用于模拟会员服务。会员服务监听用户注册成功的消息,并发送欢迎短信。我们使用 ConcurrentHashMap 来存放那些发过短信的用户 ID 实现幂等,避免相同的用户进行补偿时重复发送短信:
    • 参考代码: com.baiye.demo.case25.compensation.MemberService
  • 对于 MQ 消费程序,处理逻辑务必考虑去重(支持幂等),原因有几个:
    • MQ 消息可能会因为中间件本身配置错误、稳定性等原因出现重复。
    • 自动补偿重复,比如本例,同一条消息可能既走 MQ 也走补偿,肯定会出现重复,而且考虑到高内聚,补偿 Job 本身不会做去重处理。
    • 人工补偿重复。出现消息堆积时,异步处理流程必然会延迟。如果我们提供了通过后台进行补偿的功能,那么在处理遇到延迟的时候,很可能会先进行人工补偿,过了一段时间后处理程序又收到消息了,重复处理。我之前就遇到过一次由 MQ 故障引发的事故, MQ 中堆积了几十万条发放资金的消息,导致业务无法及时处理,运营以为程序出错了就先通过后台进行了人工处理,结果 MQ 系统恢复后消息又被重复处理了一次,造成大量资金重复发放。
  • 接下来,定义补偿 Job 也就是备线操作。
    • 我们在 CompensationJob 中定义一个 @Scheduled 定时任务5 秒做一次补偿操作,因为 Job 并不知道哪些用户注册的消息可能丢失,所以是全量补偿,补偿逻辑是:每 5 秒补偿一次,按顺序一次补偿 5 个用户,下一次补偿操作从上一次补偿的最后一个用户 ID 开 始;对于补偿任务我们提交到线程池进行“异步”处理,提高处理能力。
    • 参考代码: com.baiye.demo.case25.compensation.CompensationJob
  • 为了实现高内聚主线和备线处理消息最好使用同一个方法。比如本例中MemberService 监听到 MQ 消息和 CompensationJob 补偿,调用的都是 welcome 方法。
  • 此外值得一说的是Demo 中的补偿逻辑比较简单,生产级的代码应该在以下几个方面进行加强:
    • 考虑配置补偿的频次、每次处理数量,以及补偿线程池大小等参数为合适的值,以满足补偿的吞吐量。
    • 考虑备线补偿数据进行适当延迟。比如,对注册时间在 30 秒之前的用户再进行补偿,以方便和主线 MQ 实时流程错开,避免冲突。
    • 诸如当前补偿到哪个用户的 offset 数据,需要落地数据库。
    • 补偿 Job 本身需要高可用,可以使用类似 XXLJob 或 ElasticJob 等任务系统。
  • 执行注册方法注册 10 个用户,输出如下:
    • 总共 10 个用户MQ 发送成功的用户有四个,分别是用户 1、5、7、8。
    • 补偿任务第一次运行,补偿了用户 2、3、4第二次运行补偿了用户 6、9第三次运行补充了用户 10。
  • 最后提一下,针对消息的补偿闭环处理的最高标准是,能够达到补偿全量数据的吞吐量。也就是说,如果补偿备线足够完善,即使直接把 MQ 停机,虽然会略微影响处理的及时性,但至少确保流程都能正常执行。

B. 注意消息模式是广播还是工作队列

  • 异步处理的一个重要优势,是实现消息广播。
  • 消息广播,和我们平时说的“广播”意思差不多,就是希望同一条消息,不同消费者都能分别消费;而队列模式,就是不同消费者共享消费同一个队列的数据,相同消息只能被某一个消费者消费一次。
  • 比如,同一个用户的注册消息,会员服务需要监听以发送欢迎短信,营销服务同样需要监听以发送新用户小礼物。但是,会员服务、营销服务都可能有多个实例,我们期望的是同一个用户的消息, 可以同时广播给不同的服务(广播模式),但对于同一个服务的不同实例(比如会员服务 1 和会员服务 2不管哪个实例来处理处理一次即可工作队列模式
  • 消息模式广播
  • 在实现代码的时候,我们务必确认 MQ 系统的机制,确保消息的路由按照我们的期望。
  • 对于类似 RocketMQ 这样的 MQ 来说,实现类似功能比较简单直白:如果消费者属于一个组,那么消息只会由同一个组的一个消费者来消费;如果消费者属于不同组,那么每个组都能消费一遍消息。
  • 而对于 RabbitMQ 来说,消息路由的模式采用的是队列 + 交换器,队列是消息的载体,交换器决定了消息路由到队列的方式,配置比较复杂,容易出错。
  • 演示使用 RabbitMQ 实现广播模式和工作队列模式的坑。
  • 第一步,实现会员服务监听用户服务发出的新用户注册消息的那部分逻辑
  • 我们启动两个会员服务,那么同一个用户的注册消息应该只能被其中一个实例消费
  • 分别实现 RabbitMQ 队列、交换器、绑定三件套。其中,队列用的是匿名队列,交换器用的是直接交换器 DirectExchange交换器绑定到匿名队列的路由 Key 是空字符串。 在收到消息之后,我们会打印所在实例使用的端口:
  • 参考代码: com.baiye.demo.case25.fanoutvswork.WorkQueueWrong
  • 使用 12345 和 45678 两个端口启动两个程序实例后,调用 sendMessage 接口发送一条消息,输出的日志,显示同一个会员服务两个实例都收到了消息
  • 出现这个问题的原因是,我们没有理清楚 RabbitMQ 直接交换器和队列的绑定关系
  • 如下图所示RabbitMQ 的直接交换器根据 routingKey 对消息进行路由。由于我们的程序每次启动都会创建匿名(随机命名)的队列,所以相当于每一个会员服务实例都对应独立的队列, 以空 routingKey 绑定到直接交换器。用户服务发出消息的时候也设置了routingKey 为空,所以直接交换器收到消息之后,发现有两条队列匹配,于是都转发了消息:
  • 消息模式广播1
  • 要修复这个问题其实很简单,对于会员服务不要使用匿名队列,而是使用同一个队列即可。 把上面代码中的匿名队列替换为一个普通队列:
  • 参考代码: com.baiye.demo.case25.fanoutvswork.WorkQueueRight
  • 测试发现,对于同一条消息来说,两个实例中只有一个实例可以收到,不同的消息按照轮询分发给不同的实例。现在,交换器和队列的关系是这样的:
  • 消息模式广播2
  • 第二步,进一步完整实现用户服务需要广播消息给会员服务和营销服务的逻辑。
  • 我们希望会员服务和营销服务都可以收到广播消息,但会员服务或营销服务中的每个实例只需要收到一次消息。
  • 我们声明了一个队列和一个广播交换器 FanoutExchange然后模拟两个用户服务和两个营销服务
  • 参考代码: com.baiye.demo.case25.fanoutvswork.FanoutQueueWrong
  • 我们请求四次 sendMessage 接口,注册四个用户。通过日志可以发现,一条用户注册的消息,要么被会员服务收到,要么被营销服务收到,显然这不是广播。那我们使用的FanoutExchange看名字就应该是实现广播的交换器为什么根本没有起作用呢
  • 其实,广播交换器非常简单,它会忽略 routingKey广播消息到所有绑定的队列。在这个案例中两个会员服务和两个营销服务都绑定了同一个队列所以这四个服务只能收到一次消息
  • 消息模式广播3
  • 修改方式很简单,我们把队列进行拆分,会员和营销两组服务分别使用一条独立队列绑定到广播交换器即可:
  • 参考代码: com.baiye.demo.case25.fanoutvswork.FanoutQueueRight
  • 现在,交换器和队列的结构是这样的:
  • 消息模式广播4
  • 从日志输出可以验证,对于每一条 MQ 消息,会员服务和营销服务分别都会收到一次,一条消息广播到两个服务的同时,在每一个服务的两个实例中通过轮询接收:
  • 理解了 RabbitMQ 直接交换器、广播交换器的工作方式之后,我们对消息的路由方式了解得很清晰了,实现代码就不会出错
  • 对于异步流程来说,消息路由模式一旦配置出错,轻则可能导致消息的重复处理,重则可能导致重要的服务无法接收到消息,最终造成业务逻辑错误。
  • 每个 MQ 中间件对消息的路由处理的配置各不相同,我们一定要先了解原理再着手编码。

C. 别让死信堵塞了消息队列

  • 使用消息队列处理异步流程的时候,我们要注意消息队列的任务堆积问题。对于突发流量引起的消息队列堆积,问题并不大,适当调整消费者的消费能力应该就可以解决。但在很多时候,消息队列的堆积堵塞,是因为有大量始终无法处理的消息
  • 比如,用户服务在用户注册后发出一条消息,会员服务监听到消息后给用户派发优惠券,但因为用户并没有保存成功,会员服务处理消息始终失败,消息重新进入队列,然后还是处理失败。这种在 MQ 中像幽灵一样回荡的同一条消息,就是死信。
  • 随着 MQ 被越来越多的死信填满,消费者需要花费大量时间反复处理死信,导致正常消息的消费受阻,最终 MQ 可能因为数据量过大而崩溃
  • 我们来测试一下这个场景。首先,定义一个队列、一个直接交换器,然后把队列绑定到交换
  • 参考代码: com.baiye.demo.case25.deadletter.RabbitConfiguration.declarables
  • 然后,实现一个 sendMessage 方法来发送消息到 MQ访问一次提交一条消息使用自增标识作为消息内容
  • 参考代码: com.baiye.demo.case25.deadletter.DeadLetterController.sendMessage
  • 收到消息后,直接抛出空指针异常,模拟处理出错的情况:
  • 参考代码: com.baiye.demo.case25.deadletter.MQListener.handler
  • 调用 sendMessage 接口发送两条消息,然后来到 RabbitMQ 管理台,可以看到这两条消息始终在队列中,不断被重新投递,导致重新投递 QPS 达到了 1063。
  • 消息模式广播5
  • 同时,在日志中可以看到大量异常信息
  • 解决死信无限重复进入队列最简单的方式是在程序处理出错的时候直接抛出AmqpRejectAndDontRequeueException 异常,避免消息重新进入队列:
    • throw new AmqpRejectAndDontRequeueException("error");
  • 但,我们更希望的逻辑是,对于同一条消息,能够先进行几次重试,解决因为网络问题导致的偶发消息处理失败,如果还是不行的话,再把消息投递到专门的一个死信队列。对于来自死信队列的数据,我们可能只是记录日志发送报警, 即使出现异常也不会再重复投递。整个逻辑如下图所示:
  • 消息模式广播6
  • 针对这个问题Spring AMQP 提供了非常方便的解决方案:
  • 首先,定义死信交换器和死信队列。其实,这些都是普通的交换器和队列,只不过被我们专门用于处理死信消息。
  • 然后,通过 RetryInterceptorBuilder 构建一个 RetryOperationsInterceptor用于处理失败时候的重试。这里的策略是最多尝试 5 次(重试 4 次);并且采取指数退避重试,首次重试延迟 1 秒,第二次 2 秒,以此类推,最大延迟是 10 秒 ;如果第 4 次重试还是失败,则使用 RepublishMessageRecoverer 把消息重新投入一个“死信交换器”中。
  • 最后,定义死信队列的处理程序。这个案例中,我们只是简单记录日志。
  • 参考代码: com.baiye.demo.case25.deadletter.RabbitConfiguration | com.baiye.demo.case25.deadletter.MQListener.deadHandler
  • 执行程序,发送两条消息:
  • msg1 的 4 次重试间隔分别是 1 秒、2 秒、4 秒、8 秒,再加上首次的失败,所以最大尝试次数是 5。
  • 4 次重试后RepublishMessageRecoverer 把消息发往了死信交换器。
  • 死信处理程序输出了 got dead message 日志。
  • 这里需要尤其注意的一点是,虽然我们几乎同时发送了两条消息,但是 msg2 是在 msg1的四次重试全部结束后才开始处理。原因是默认情况下SimpleMessageListenerContainer 只有一个消费线程。可以通过增加消费线程来避免 性能问题,如下我们直接设置 concurrentConsumers 参数为 10来增加到 10 个工作线程:
  • 当然,我们也可以设置 maxConcurrentConsumers 参数来让SimpleMessageListenerContainer 自己动态地调整消费者线程数。不过,我们需要特别注意它的动态开启新线程的策略。你可以通过官方文档,来了解这个策略

D. 总结

  • 在使用异步处理这种架构模式的时候,我们一般都会使用 MQ 中间件配合实现异步流程,需要重点考虑四个方面的问题。
    • 第一,要考虑异步流程丢消息或处理中断的情况,异步流程需要有备线进行补偿。比如,我们今天介绍的全量补偿方式,即便异步流程彻底失效,通过补偿也能让业务继续进行。
    • 第二,异步处理的时候需要考虑消息重复的可能性,处理逻辑需要实现幂等,防止重复处理。
    • 第三,微服务场景下不同服务多个实例监听消息的情况,一般不同服务需要同时收到相同的消息,而相同服务的多个实例只需要轮询接收消息。我们需要确认 MQ 的消息路由配置是否满足需求,以避免消息重复或漏发问题。
    • 第四,要注意始终无法处理的死信消息,可能会引发堵塞 MQ 的问题。一般在遇到消息处理失败的时候,我们可以设置一定的重试策略。如果重试还是不行,那可以把这个消息扔到专有的死信队列特别处理,不要让死信影响到正常消息的处理

E. 补充

  • 基于canal做mysql数据同步需要将解析好的数据发到kafka里
    • 发现这么一个问题就是kafka多partition消费时不能保证消息的顺序消费进而导致mysql数据同步异常。
    • 由于kafka可以保证在同一个partition内消息有序于是我自定义了一个分区器将数据的id取hashcode然后根据partition的数量取余作为分区号保证同一条数据的binlog能投递有序
  • 在用户注册后发送消息到 MQ然后会员服务监听消息进行异步处理的场景下有些时候我们会发现虽然用户服务先保存数据再发送 MQ但会员服务收到消息后去查询数据库却发现数据库中还没有新用户的信息。
    • 建立本地消息表来确保MQ消息可补偿把业务处理和保存MQ消息到本地消息表操作在相同事务内处理然后异步发送和补偿发送消息表中的消息到MQ

3.26 设计 - 数据存储NoSQL与RDBMS如何取长补短、相辅相成

  • NoSQL 一般可以分为缓存数据库、时间序列数据库、全文搜索数据库、文档数据库、图数据库等。
  • 代表: 缓存数据库 Redis、时间序列数据库 InfluxDB、全文搜索数据库 ElasticSearch

A. 取长补短之 Redis vs MySQL

  • Redis 是一款设计简洁的缓存数据库,数据都保存在内存中,所以读写单一 Key 的性能非常高
  • 自定义一个测试案例进行对比:
  • MySQL 和 Redis 随机读取单条数据的性能
    • 案例代码: com.baiye.demo.case26.redisvsmysql
  • 使用 wrk 加 10 个线程 50 个并发连接做压测
wrk -t 10 -c 50 10s http://localhost:45678/redisvsmysql/mysql --latency
  • 可以看到MySQL 90% 的请求需要 61msQPS 为 1460而 Redis 90% 的请求在 5ms 左右QPS 达到了14008几乎是 MySQL 的十倍
  • MysqlVsRedis1
  • 但 Redis 薄弱的地方是,不擅长做 Key 的搜索。对 MySQL我们可以使用 LIKE 操作前匹配走 B+ 树索引实现快速搜索;但对 Redis我们使用 Keys 命令对 Key 的搜索,其实相当于在 MySQL 里做全表扫描。
    • 案例代码: com.baiye.demo.case26.redisvsmysql.PerformanceController# redis2 | mysql2
  • 可以看到,在 QPS 方面MySQL 的 QPS 达到了 Redis 的 157 倍在延迟方面MySQL 的延迟只有 Redis 的十分之一
  • MysqlVsRedis2
  • Redis 慢的原因有两个:
    • Redis 的 Keys 命令是 O(n) 时间复杂度。如果数据库中 Key 的数量很多,就会非常慢。
    • Redis 是单线程的,对于慢的命令如果有并发,串行执行就会非常耗时。
  • 我们使用 Redis 都是针对某一个 Key 来使用,而不能在业务代码中使用 Key命令从 Redis 中“搜索数据”,因为这不是 Redis 的擅长。
  • 对于 Key 的搜索,我们可以先通过关系型数据库进行,然后再从 Redis 存取数据(如果实在需要搜索 Key 可以使用SCAN 命令)。
  • 在生产环境中,我们一般也会配置 Redis 禁用类似 Keys 这种比较危险的命令
  • 总结
    • 对于业务开发来说大多数业务场景下Redis 是作为关系型数据库的辅助用于缓存的,我们一般不会把它当作数据库独立使用。
    • Redis 提供了丰富的数据结构Set、SortedSet、Hash、List并围绕这些数据结构提供了丰富的 API。如果我们好好利用这个特点的话可以直接在 Redis中完成一部分服务端计算避免“读取缓存 -> 计算数据 -> 保存缓存”三部曲中的读取和 保存缓存的开销,进一步提高性能。

B. 取长补短之 InfluxDB vs MySQL

  • 时序数据库的优势,在于处理指标数据的聚合,并且读写效率非常高。
  • 测试来对比下 InfluxDB 和 MySQL 的性能。
    • 我们分别填充了 1000 万条数据到 MySQL 和 InfluxDB 中。其中,每条数据只有 ID、时间戳、10000 以内的随机值这 3 列信息,对于 MySQL 我们把时间戳列做了索引:
    • 案例代码 com.baiye.demo.case26.influxdbvsmysql
    • InfluxDB 批量插入 1000 万条数据仅用了 54 秒,相当于每秒插入 18 万条数据速度相当快MySQL 的批量插入,速度也挺快达到了每秒 4.8 万。
  • 对这 1000 万数据进行一个统计,查询最近 60 天的数据,按照 1 小时的时间粒度聚合,统计 value 列的最大值、最小值和平均值,并将统计结果绘制成曲线图:
    • 案例代码 com.baiye.demo.case26.influxdbvsmysql.PerformanceController # mysql influxdb
    • 因为数据量非常大,单次查询就已经很慢了,所以这次我们不进行压测。分别调用两个接口,可以看到 MySQL 查询一次耗时 29 秒左右,而 InfluxDB 耗时 980ms
  • 按照时间区间聚合的案例上,我们看到了 InfluxDB 的性能优势。但,我们肯定不能把InfluxDB 当作普通数据库,原因是:
    • InfluxDB 不支持数据更新操作,毕竟时间数据只能随着时间产生新数据,肯定无法对过去的数据做修改;
    • 从数据结构上说,时间序列数据数据没有单一的主键标识,必须包含时间戳,数据只能和时间戳进行关联,不适合普通业务数据。
  • 此外需要注意,即便只是使用 InfluxDB 保存和时间相关的指标数据,我们也要注意不能滥用 tag
    • InfluxDB 提供的 tag 功能,可以为每一个指标设置多个标签,并且 tag 有索引可以对tag 进行条件搜索或分组。但是tag 只能保存有限的、可枚举的标签,不能保存 URL 等 信息否则可能会出现high series cardinality 问题导致占用大量内存甚至是OOM。可以查看 series 和内存占用的关系。
    • 对于 InfluxDB我们无法把URL 这种原始数据保存到数据库中,只能把数据进行归类,形成有限的 tag 进行保存。
  • 总结
    • MySQL 而言,针对大量的数据使用全表扫描的方式来聚合统计指标数据,性能非常差,一般只能作为临时方案来使用。
    • 时间序列数据库可以作为特定场景(比如监控、统计)的主存储,也可以和关系型数据库搭配使用,作为一个辅助数据源,保存业务系统的指标数据。

C. 取长补短之 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 的实体:
    • 案例代码 com.baiye.demo.case26.esvsmyql
      • News 实体, 同时会用作Spring Data JPA 和 Spring Data Elasticsearch 的实体:
      • CommonMistakesApplication 主程序, 在启动时,我们会从一个 csv 文件中加载 4000 条新闻数据,然后复制 100 份,拼成 40 万条数据,分别写入 MySQL 和 ES
      • NewsESRepository | NewsMySQLRepository 使用了 Spring Data直接定义两个 Repository然后直接定义查询方法无需实现任何逻辑即可实现查询Spring Data 会根据方法名生成相应的 SQL 语句和 ES 查询 DSL
        • 在这里,我们定义一个 countByCateidAndContentContainingAndContentContaining方法代表查询条件是搜索分类等于 cateid 参数,且内容同时包含关键字 keyword1 和keyword2计算符合条件的新闻总数量
      • PerformanceController 我们使用相同的条件进行搜索,搜素分类是 1关键字是社会和苹果然后输出搜索结果和耗时
    • 分别调用接口可以看到,ES 耗时仅仅 48msMySQL 耗时 6 秒多是 ES 的 100 倍。很遗憾,虽然新闻分类 ID 已经建了索引,但是这个索引只能起到加速过滤分类 ID 这一单一条件的作用对于文本内容的全文搜索B+ 树索引无能为力。
  • ES 这种以索引为核心的数据库,也不是万能的,频繁更新就是一个大问题。
  • MySQL 可以做到仅更新某行数据的某个字段,但 ES 里每次数据字段更新都相当于整个文档索引重建。即便 ES 提供了文档部分更新的功能,但本质上只是节省了提交文档的网络流量,以及减少了更新冲突,其内部实现还是文档删除后重新构建索引。 因此,如果要在 ES 中保存一个类似计数器的值,要实现不断更新,其执行效率会非常低。
  • 分别使用 JdbcTemplate+SQL 语句、ElasticsearchTemplate+ 自定义 UpdateQuery实现部分更新 MySQL 表和 ES 索引的一个字段每个方法都是循环更新1000 次:
    • 案例代码 com.baiye.demo.case26.esvsmyql.PerformanceController #mysql2 #es2
    • 可以看到MySQL 耗时仅仅 1.5 秒,而 ES 耗时 6.8 秒:
  • 总结
    • ES 是一个分布式的全文搜索数据库,所以与 MySQL 相比的优势在于文本搜索,而且因为其分布式的特性,可以使用一个大 ES 集群处理大规模数据的内容搜索。但,由于 ES 的索引是文档维度的,所以不适用于频繁更新的 OLTP 业务。
    • 一般而言,我们会把 ES 和 MySQL 结合使用MySQL 直接承担业务系统的增删改操作, 而 ES 作为辅助数据库,直接扁平化保存一份业务数据,用于复杂查询、全文搜索和统计。

D. 结合 NoSQL 和 MySQL 应对高并发的复合数据库架构

  • 每一个存储系统都有其独特的数据结构,数据结构的设计就决定了其擅长和不擅长的场景。

  • MySQL InnoDB 引擎的 B+ 树对排序和范围查询友好,频繁数据更新的代价不是太大,因此适合 OLTPOn-Line Transaction Processing

  • 又比如ES 的 Lucene 采用了 FSTFinite State Transducer索引 + 倒排索引,空间效率高,适合对变动不频繁的数据做索引,实现全文搜索。存储系统本身不可能对一份数据使用多种数据结构保存,因此不可能适用于所有场景。

  • 虽然在大多数业务场景下MySQL 的性能都不算太差但对于数据量大、访问量大、业务复杂的互联网应用来说MySQL 因为实现了 ACID原子性、一致性、隔离性、持久性会比较重而且横向扩展能力较差、功能单一无法扛下所有数据量和流量无法应对所有 功能需求。因此我们需要通过架构手段来组合使用多种存储系统取长补短实现1+1>2 的效果

  • 例子:

  • 我们设计了一个包含多个数据库系统的、能应对各种高并发场景的一套数据服务的系统架构,其中包含了同步写服务、异步写服务和查询服务三部分,分别实现主数据库写入、辅助数据库写入和查询路由。

  • 按照服务来依次分析下这个架构。

  • 数据库架构

  • 首先要明确的是,重要的业务主数据只能保存在 MySQL 这样的关系型数据库中,原因有三点:

    • RDBMS 经过了几十年的验证,已经非常成熟;
    • RDBMS 的用户数量众多Bug 修复快、版本稳定、可靠性很高;
    • RDBMS 强调 ACID能确保数据完整
  • 有两种类型的查询任务可以交给 MySQL 来做,性能会比较好,这也是 MySQL 擅长的地方:

    • 按照主键 ID 的查询。直接查询聚簇索引其性能会很高。但是单表数据量超过亿级后性能也会衰退而且单个数据库无法承受超大的查询并发因此我们可以把数据表进行Sharding 操作均匀拆分到多个数据库实例中保存。我们把这套数据库集群称作Sharding 集群。
    • 按照各种条件进行范围查询,查出主键 ID。对二级索引进行查询得到主键只需要查询一棵 B+ 树,效率同样很高。但索引的值不宜过大,比如对 varchar(1000) 进行索引不太合适,而索引外键(一般是 int 或 bigint 类型)性能就会比较好。因此,我们可以在 MySQL 中建立一张“索引表”,除了保存主键外,主要是保存各种关联表的外键,以及尽可能少的 varchar 类型的字段。这张索引表的大部分列都可以建上二级索引,用于进行简单搜索,搜索的结果是主键的列表,而不是完整的数据。由于索引表字段轻量并且数量不多 (一般控制在 10 个以内),所以即便索引表没有进行 Sharding 拆分,问题也不会很大。
  • 如图上蓝色线所示,写入两种 MySQL 数据表和发送 MQ 消息的这三步,我们用一个同步写服务完成了。在“异步处理”中提到,所有异步流程都需要补偿,这里的异步流程同样需要。只不过为了简洁,这里省略了补偿流程。

  • 然后,如图中绿色线所示,有一个异步写服务,监听 MQ 的消息,继续完成辅助数据的更新操作。这里我们选用了 ES 和 InfluxDB 这两种辅助数据库,因此整个异步写数据操作有三步:

    • 1- MQ 消息不一定包含完整的数据,甚至可能只包含一个最新数据的主键 ID我们需要根据 ID 从查询服务查询到完整的数据。
    • 2- 写入 InfluxDB 的数据一般可以按时间间隔进行简单聚合,定时写入 InfluxDB。因此这里会进行简单的客户端聚合然后写入 InfluxDB。
    • 3- ES 不适合在各索引之间做连接Join操作适合保存扁平化的数据。比如我们可以把订单下的用户、商户、商品列表等信息作为内嵌对象嵌入整个订单 JSON然后把整个扁平化的 JSON 直接存入 ES。
  • 对于数据写入操作,我们认为操作返回的时候同步数据一定是写入成功的,但是由于各种原因,异步数据写入无法确保立即成功,会有一定延迟,比如:

    • 异步消息丢失的情况,需要补偿处理;
    • 写入 ES 的索引操作本身就会比较慢;
    • 写入 InfluxDB 的数据需要客户端定时聚合。
  • 因此,对于查询服务,如图中红色线所示,我们需要根据一定的上下文条件(比如查询一致性要求、时效性要求、搜索的条件、需要返回的数据字段、搜索时间区间等)来把请求路由到合适的数据库,并且做一些聚合处理:

    • 需要根据主键查询单条数据,可以从 MySQL Sharding 集群或 Redis 查询,如果对实时性要求不高也可以从 ES 查询。
    • 按照多个条件搜索订单的场景,可以从 MySQL 索引表查询出主键列表,然后再根据主键从 MySQL Sharding 集群或 Redis 获取数据详情。
    • 各种后台系统需要使用比较复杂的搜索条件,甚至全文搜索来查询订单数据,或是定时分析任务需要一次查询大量数据,这些场景对数据实时性要求都不高,可以到 ES 进行搜索。此外MySQL 中的数据可以归档,我们可以在 ES 中保留更久的数据,而且查询历 史数据一般并发不会很大,可以统一路由到 ES 查询。
    • 监控系统或后台报表系统需要呈现业务监控图表或表格,可以把请求路由到 InfluxDB 查询。
  • case总结

  • 对比

    • Redis 对单条数据的读取性能远远高于 MySQL但不适合进行范围搜索。
    • InfluxDB 对于时间序列数据的聚合效率远远高于 MySQL但因为没有主键所以不是一个通用数据库。
    • ES 对关键字的全文搜索能力远远高于 MySQL但是字段的更新效率较低不适合保存频繁更新的数据。
  • 混合使用 MySQL + Redis + InfluxDB + ES 的架构方案

    • 充分发挥了各种数据库的特长,相互配合构成了一个可以应对各种复杂查询,以及高并发读写的存储架构。
      • 主数据由两种 MySQL 数据表构成其中索引表承担简单条件的搜索来得到主键Sharding 表承担大并发的主键查询。主数据由同步写服务写入,写入后发出 MQ 消息。
      • 辅助数据可以根据需求选用合适的 NoSQL由单独一个或多个异步写服务监听 MQ 后异步写入。
      • 由统一的查询服务,对接所有查询需求,根据不同的查询需求路由查询到合适的存储,确保每一个存储系统可以根据场景发挥所长,并分散各数据库系统的查询压力。
  • case补充

  • 对于一个高并发的系统,索引库保存多长时间, 清理方案?

    • 至少三个月 取决于业务上让用户查多久的订单 更长期地打到es
  • 多数据库系统例子里redis写入是怎么实现的呢

    • 取决于缓存的使用策略比如Cache aside, Read through, Write through可以根据你需要的方案把写入redis的操作落到读服务或同步写服务去实现。
  • influxdb,和es 这些数据库做辅助数据库,需要保存全量数据吗?还是根据业务保存部分字段?

    • es一般是保存全量数据甚至是超全量数据意思就是比mysql保存的数据还要全。influxdb 作为时间序列数据库只能保存加工后的业务或系统指标数据,无法保存实际的业务数据。
  • mysql索引库 是指表上建立了索引的库吗?

    • 是指这个表大多字段都建了索引主要用于简单搜索索引库对比Sharding库
  • mongo数据库的使用建议

    • 非重要数据并且数据结构不固定的插入量又很大的原始数据比如爬虫原始数据可以考虑Mongo。从个人喜好而言综合性NOSQL我更喜欢ES而不是MongoMongo在数据量到TB级别我感觉不稳定Sharding也不那么好用