This the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

架构模式

系统架构与设计模式并没有绝对标准,都是前人的经验总结。一切以你的需求场景为准。

2 - 设计模式

2.1 - Java 模式

2.1.1 - CH01-创建型-简单工厂

创建型模式概述

创建型模式(Creational Pattern)对类的实例化过程进行了抽象,能够将软件模块中对象的创建和使用分离。为了使软件的结构更加清晰,外界对于这些对象只需要知道他们的共同接口,而不清楚其具体细节,使整个系统的设计更加符合单一职责原则。

该模式在创建什么(what)、由谁创建(who)、何时创建(when)等方面都为软件设计者提供了尽可能大的灵活性。创建型模式隐藏了类实例的创建细节,通过隐藏对象如何被创建和组合在一起达到使整个系统独立的目的。

简单工厂(Simple Factory Pattern)

模式动机

比如一个软件系统可以提供多个外观不同的按钮(圆形、矩形、菱形等),这些按钮都源自同一个基类,在继承基类后不同的子类修改了部分属性从而使得他们可以呈现不同的外观,如我们在使用这些按钮时,不需要知道这些具体按钮类的名字,只需要知道表示该按钮类的一个参数,并提供一个调用方便的方法,把参数传入该方法即可返回一个相应的按钮对象,这时就可以使用该模式。

模式定义

又称为静态工厂方法(Static Factory Method),它属于类创建型模式。该模式中,可以根据参数的不同返回不同类的实例。简单工厂模式专门定义一个类来负责创建其他类的实例,这些被创建的实例通常拥有共同的父类。

模式结构

简单工厂模式包含如下角色:

  • Factory:工厂角色

    负责实现创建所有实例的内部逻辑。

  • Produce:抽象产品角色

    是创建的所有对象的父类,负责描述所有实例所共有的公共接口

  • ConcreteProduce:具体产品角色

    是创建目标,所有创建的对象多充当这个角色某个具体类的实例

类图

NAME

时序图

NAME

代码实例

C++

#include "Factory.h"
#include "ConcreteProductA.h"
#include "ConcreteProductB.h"
Product* Factory::createProduct(string proname){
	if ( "A" == proname )
	{
		return new ConcreteProductA();
	}
	else if("B" == proname)
	{
		return new ConcreteProductB();
	}
	return  NULL;
}

模式分析

  • 对象的创建对象的业务处理分离可以降低系统的耦合度,使得两者修改起来更简便。
  • 在调用工厂类的工厂方法时,由于工厂方法是静态方法,使用方便,可通过类名直接调用,而且只需要传入一个简单的参数即可。在实际开发中,还可以在调用时将所传入的参数保存在 XML 等配置文件中,修改参数时无需修改任何源代码。
  • 该模式最大的问题在于工厂类的职责过重,增加新的产品需要修改工厂类的逻辑判断,这一点与开闭原则是相违背的。
  • 该模式的要点在于:当你需要什么,只需要传入一个正确的参数,就可以获得你需要的对象,而无需知道其创建细节。

优点

  • 工厂类含有必要的逻辑判断,可以决定什么时候(条件)创建什么类的实例,客户端可以免除直接创建产品对象的责任,而仅仅“消费”(使用)产品。通过这种方式实现对责任的分割,提供专门的工厂类用于创建对象。
  • 客户端无需知道所创建的具体产品类的类名,只需要知道具体产品类对应的参数即可,对于一些复杂的类名,通过该模式可以减少使用者的记忆量。
  • 通过引入配置文件,可以在不修改任何客户端代码的情况下更换个增加新的产品类,在一定程度上提高了系统的灵活性。

缺点

  • 由于工厂类中集中了所有产品类的创建逻辑,一旦不能正常工作,将影响整个系统。
  • 该模式将会增加系统中类的个数,在一定程度上增加了系统的复杂度和立即难度。
  • 系统扩展困难,一旦添加新的产品类则必须修改工厂逻辑,在产品类型较多时,有可能造成工厂逻辑较为复杂,不利于系统的扩展和维护。
  • 由于使用了静态工厂方法,造成工厂角色无法形成基于继承的等级结构。

适用场景

  • 工厂类负责创建的对象比较少:由于创建的对象类别少,不会造成工厂方法中的业务逻辑太过复杂。
  • 客户端只知道传入工厂类的参数,对于如何创建对象不关心:客户端既不需要关心创建细节,也不需要知道类名,只需要知道该类型需要的参数。

模式应用

  1. JDK 类库中广泛使用了该模式,如工具类java.text.DateFormat,用于格式化一个本地日期:

    public final static DateFormat getDateInstance();
    public final static DateFormat getDateInstance(int style);
    public final static DateFormat getDateInstance(int style, Local local);
    
  2. Java 加密技术:

    1. 获取不同加密算法的密钥生成器:

      KeyGenerator keyGen = KeyGenerator.getInstance("DESede");
      
    2. 创建密码器:

      Cipher cp = Cipher.getInstance("DESede");
      

总结

  • 创建型模式对类的实例化过程进行了抽象,能够将对象的创建与对象的使用分离。
  • 简单工厂模式又称为静态工厂方法模式,属于类创建型模式。在该模式中,可以根据参数的不同返回不同类的实例。同时专门定义了一个类来负责创建其他类的实例,被创建的实例通常都具有共同的父类。
  • 包含三个角色:工厂角色负责实现创建所有实例的内部逻辑;抽象产品角色是需要创建的所有类的父类,负责描述所有实例所共有的公共接口;具体产品角色是创建目标,所有创建的对象都充当这个角色某个具体类的实例。
  • 要点在于:当你需要什么,只需要传入对应正确的参数,就可以获得对应的对象,而无需知道创建细节。
  • 有点在于:将对象的创建与使用分离,将对象的创建交给工厂类负责,但是其最大的缺点在于工厂类不够灵活,增加新的具体产品则需要修改工厂的判断逻辑,当产品较多时,逻辑将会复杂。
  • 适用场景:工厂类需要创建的对象比较少;客户端只知道传入工厂类的参数,对于创建过程不关心。

2.1.2 - CH02-创建型-工厂方法

模式动机

针对“1-简单工厂”中提到的系统进行修改,不再使用一个按钮工厂类来统一负责所有产品的创建,而是将具体按钮的创建过程交给专门的工厂子类去完成。首先定义一个抽象的按钮工厂类,再定义具体的工厂类来生成圆形、矩形、菱形按钮等,这些具体的工厂类会实现抽象工厂类中定义的方法。

这种抽象化的结构可以在不修改具体工厂类的情况下引入新的产品,如果出现新的按钮类型,只需要为这种新的按钮类型创建对应的工厂类即可获得该新按钮的实例。因此,使得工厂方法模式具有超越简单工厂的优越性,更加符合开闭原则

模式定义

**工厂方法模式(Factory Method Pattern)**又称工厂模式,或虚拟构造器(Virtual Constructor)模式、多态工厂(Polymorphic Factory),属于类创建型模式。该模式中,工厂父类负责负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象,将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪个具体产品类。

模式结构

工厂方法包含的角色:

  1. Product:抽象产品
  2. ConcreteProduct:具体产品
  3. Factory:抽象工厂
  4. ConcreteFactory:具体工厂

类图

NAME

时序图

NAME

代码示例

#include "ConcreteFactory.h"
#include "ConcreteProduct.h"

Product* ConcreteFactory::factoryMethod(){

	return  new ConcreteProduct();
}
#include "Factory.h"
#include "ConcreteFactory.h"
#include "Product.h"
#include <iostream>
using namespace std;

int main(int argc, char *argv[])
{
	Factory * fc = new ConcreteFactory();
	Product * prod = fc->factoryMethod();
	prod->use();
	
	delete fc;
	delete prod;
	
	return 0;
}

模式分析

由于使用了面向对象的多态性,工厂方法模式保持了简单工厂模式的优点,而且克服了它的缺点。在该模式中,核心的工厂类不再负责所有产品的创建,而是将具体创建工作交给子类去做。这个核心类仅仅负责给出具体工厂必须实现的接口,而不负责哪一个产品类被实例化这个细节,使得工厂方法模式可以允许系统在不修改工厂角色的情况下引进新产品。

实例

日志记录器

某系统日志记录器要求支持多种日志记录方式,如文件、数据库等。且用户可以根据要求动态选择日志记录方式,使用工厂方法进行设计:

类图:

NAME

时序图:

NAME

优点

  • 该模式中,工厂方法用来创建用户所需要的产品,同时还向客户隐藏了哪种具体产品类将被实例化这一细节,用户只需要关心所需产品对应的工厂,无需关心创建细节,甚至无需知道具体产品类的类名。
  • 基于工厂角色和产品角色的多态性设计是工厂方法模式的关键。它能使工厂可以自主确定创建何种产品对象,而如何创建这个对象的细节则完全封装在具体工厂内部。
  • 在加入新产品时,无需修改抽象工厂和抽象产品提供的接口,无需修改客户端,也无需修改其他的具体工厂和具体产品,只需要添加一个具体工厂和具体产品就可以了。增加了扩展性,符合开闭原则。

缺点

  • 在添加新产品时需要编写新的产品类,还要提供于此对应的具体工厂类,类的个数会成对增加,一定程度上增加了复杂性。
  • 引入了抽象层,客户端的代码均使用抽象层进行定义,增加了系统的抽象性和理解难度。

适用场景

  • 无需知道需要创建对象的类:该模式中,客户端不需要知道具体产品类的类名,只需要知道他对应的工厂即可,具体的产品对象由具体工厂类创建。
  • 通过子类来指定创建哪个对象:该模式中,对于抽象工厂类只需要提供一个创建产品的接口,由其子类来确定具体要创建的对象,利用面向对象的多态性和里氏替换原则,在程序运行时,子类对象将覆盖父类对象,从而使系统更易扩展。
  • 将创建对象的任务委托给多个工厂子类中的某一个,客户端在使用时无需关心是哪个工厂子类来创建产品子类,需要时再动态指定,可将具体工厂类的类名存储在配置文件或数据库中。

模式应用

JDBC 中的工厂方法:

Connection conn=DriverManager.getConnection("jdbc:microsoft:sqlserver://localhost:1433;DatabaseName=DB;user=sa;password=");
Statement statement=conn.createStatement();
ResultSet rs=statement.executeQuery("select * from UserInfo");

模式扩展

  • 使用多个工厂方法:才抽象工厂中可以定义多个工厂方法,从而使具体工厂角色实现这些不同的工厂方法,这些方法可以包含不同的业务逻辑,以满足不同的产品对象的需求。
  • 产品对象的重复使用:工厂对象将已创建过的产品保存到一个集合,然后根据客户端对产品的请求,对集合进行查询。
  • 多态性的丧失和模式的退化:如果工厂仅仅返回一个具体产品对象,便违背了工厂方法的用意,发生退化,此时就不再是工厂方法模式了。工厂对象应该有一个抽象的父类型,如果工厂等级结构中只有一个工厂类的话,抽象工厂可以省略,则发生退化;当只有一个具体工厂,在具体工厂中创建所有产品对象,并且工厂方法设计为静态方法时,该模式就退化成了简单工厂模式。

总结

  • 工厂方法模式又称为工厂模式,属于类创建型模式。该模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象,以将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。
  • 工厂方法模式包含四个角色:
    1. 抽象产品是定义产品的接口,是工厂方法模式所创建对象的超类型,即产品对象的共同父类或接口;
    2. 具体产品实现了抽象产品接口,某种类型的具体具体产品由专门的具体工厂创建,他们之间往往一一对应
    3. 抽象工厂中声明了工厂方法,用于返回一个产品,它是该模式的核心,任何在模式中创建对象的工厂类都必须实现该接口;
    4. 具体工厂是抽象工厂的子类,实现了抽象工厂中定义的方法,并可由客户调用,返回一个具体产品类的实例。
  • 该模式是简单工厂模式的进一步抽象和推广。由于使用了面向对象的多态性,工厂方法模式保持了简单工厂模式的优点,同时克服了它的缺点。核心的工厂类不再负责创建所有类型的产品,而是将其创建交给具体子类去做。该核心类仅负责给出具体工厂必须实现的接口,而不负责产品类被实例化这种细节,这使得该模式可以允许系统在不修改工厂角色的情况下引进新产品。
  • 主要优点是增加新产品而无需修改现有系统,并封装了产品对象的创建细节,系统具有良好的灵活性和可扩展性;缺点在于增加新产品的同时需要增加对应的具体工厂,导致类的个数增长,一定程度上增加了系统的复杂性。
  • 该模式适合的情况包括:一个类不知道他所需要的对象的类;一个类通过其子类来指定创建哪个对象;将创建对象的任务委托给多个工厂子类中的某一个,客户端在使用时可以无需关心是哪一个工厂子类创建产品子类,需要时再动态绑定。

2.1.3 - CH03-创建型-抽象工厂

模式动机

  • 在工厂方法模式中,具体工厂负责生成具体的产品,每个具体工厂对应一个具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或一组重载的工厂方法。但是有些时候我们需要一个工厂能够提供多个产品对象,而不是一个单一的产品对象。

    为了清晰理解工厂方法模式,需要知道两个概念:

    1. 产品等级结构:产品等级结构即产品的继承结构(如一个抽象类是电视机,其子类有:海尔、海信、TCL等,抽象电视机与对应品牌的电视机即构成了产品等级结构)。
    2. 产品族:在抽象工厂模式中,产品族是指由一个工厂生产的,位于不同产品等级结构中的一组产品。如海尔电器工厂生产的电视机、电冰箱,海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中。
  • 当系统提供的工厂需要生产的具体产品并不是一个简单的对象,而是多个位于不同产品等级结构中、属于不同类型的具体产品时,需要使用抽象工厂模式。

  • 该模式是所有形式的工厂模式中最为抽象和最具一般性的形态。

  • 抽象工厂模式与工厂方法模式最大的区别在于:工厂方法模式针对的是一个产品等级结构,而抽象工厂模式需要面对多个产品等级结构,一个工厂等级结构,可以负责多个不同产品等级结构中的产品对象的创建。当一个工厂等级结构(海尔电器工厂),可以创建出分属不同产品等级结构的 一个产品族中的所有对象(电视机、电冰箱)时,抽象工厂模式比工厂方法模式更有效率。

模式定义

抽象工厂模式(Abstract Factory Pattern):提供一个 创建一系列相关或相互依赖对象 的接口,而无需指定他们的具体类。又称为 Kit 模式。

模式结构

包含如下角色:

  • AbstractFactory:抽象工厂
  • ConcreteFactory:具体工厂
  • AbstractProduct:抽象产品
  • Product:具体产品

类图

NAME

解释:

  • AbstractFactory 可以理解为 电器工厂;
  • ConcreteFactory1 可以理解为 海尔电器工厂;
  • ConcreteFactory2 可以理解为 格力电器工厂;
  • AbstractProductA 理解为产品 电视机;
  • AbstractProductB 理解为产品 电冰箱;
  • ProductA1 则为 海尔牌电视机;
  • ProductA2 则为 格力牌电视机;
  • ProductB1 则为 海尔牌电冰箱;
  • ProductB2 则为 格力牌电视机;

因此,海尔电器工厂 可以生产 海尔牌的电视机和电冰箱,同理格力电器工厂。这样,产品等级结构(电视机、电冰箱)和产品族(海尔牌、格力牌)通过两种维度实现对产品的建模。

时序图

NAME

优点

  • 抽象工厂模式隔离了具体类的生产,使得客户端并不知道什么被创建。由于这种隔离,更换一个具体工厂就变得相对容易。所有的具体工厂都实现了抽象工厂中定义的那些公共接口,因此只需改变具体工厂的实例,就可以从某种程度上改变整个系统的行为。同时该模式可以实现高内聚低耦合的实际目的,因此该模式被广泛应用。
  • 当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用一个产品族中的对象。这对一些需要根据当前环境来决定其行为的软件系统来说,是一种非常实用的设计模式。
  • 增加新的具体工厂和产品族非常方便,无需修改已有系统,符合开闭原则

缺点

  • 在添加新的产品对象时,难以扩展抽象工厂来生产新种类的产品,这是因为在抽象工厂角色中规定了所有可能被创建的产品集合,要支持新种类的产品就要对该接口进行扩展,这会涉及到对抽象工厂角色及其所有子类进行修改,会带来较大不便。
  • 开闭原则的倾斜性,增加新的工厂和产品族容易,增加新的产品等级结构麻烦。

适用场景

  • 当一个系统不需要依赖于产品类实例如何被创建、组合、表达的细节,这对于所有形式的工厂模式都是重要的。
  • 系统中有多于一个的产品族,而每次只适用其中一个产品族。
  • 属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。
  • 系统提供一个产品类的库,所有的产品以同样的接口出现,从而使客户端不依赖于实现。

模式应用

比如一些软件系统中需要更换系统主题,要求界面中的按钮、文本框、背景等一起发生改变时,就可以使用该模式。比如:按钮元素的不同形式构成一个产品等级结构,不同元素的同一主题形式构成一个产品族。

模式扩展

开闭原则的倾斜性:

  • 开闭原则要求对系统扩展开发,对修改关闭,通过扩展达到增强其功能的目的。对于涉及到多个产品族与多个产品等级结构的系统,其功能增强则包括两方面:
    1. 增加产品族:对于增加新的产品族,本模式很好的支持了开闭原则,只需要增加一个新的具体工厂即可,对已有代码无需做任何修改;
    2. 增加产品等级结构:对于增加新的产品等级结构,需要修改所有的工厂角色,包括抽象工厂类,在所有的工厂类中都需要增加生成新产品的方法,不能很好的支持开闭原则。
  • 抽象工厂模式的这种性质称为开闭原则的倾斜性,该模式以一种倾斜的方式来支持增加新的产品,为新产品族的增加提供方便,但不能为新的产品等级结构增加提供方便。

工厂模式退化:

  • 当抽象工厂模式中每一个具体工厂类只创建一个产品对象,也就是只存在一个产品等级结构时,该模式也就退化成了工厂方法模式;
  • 当工厂方法模式中抽象工厂与具体工厂合并,提供一个统一的工厂来创建产品对象,并将创建产品的方法设计为静态方法时,工厂方法模式退化成简单工厂模式。

总结

  • 该模式提供了一个创建一系列相关或相互依赖对象的接口,而无需指定他们的具体类。
  • 包含四种角色。
  • 是所有形式的工厂模式中最为抽象和最具一般性的一种形态。
  • 主要优点是隔离了具体类的生成,使得客户端不知道什么被创建。
  • 。。。

2.1.4 - CH04-创建型-建造者

模式动机

一些复杂的对象,拥有多个组成部分,比如汽车,包括车轮、方向盘、发动机等。对于大多数用户而言,无需知道这些部件的装配细节,也几乎不会使用单独某个部件,而是使用一辆完整的汽车。可以通过建造者模式对其进行设计与描述,建造者模式可以将其部件和其组装过程分开,逐步创建一个对象。用户只需要指定复杂对象的类型就可以得到该对象,而无需知道其内部的具体构造细节。

软件系统中也存在大量类似汽车的复杂对象,拥有一系列成员和属性,这些成员属性中有些是引用类型的成员对象。而且这些复杂对象中,还可能存在一些限制条件,如某些属性赋值则复杂对象不能作为一个完整的产品使用;有些属性的赋值必须按照某个顺序,一个属性没有赋值之前,另一个属性可能无法赋值等。

复杂对象相当于一辆有待建造的汽车,而对象的属性相当于汽车的部件,建造产品的过程就相当于组合部件的过程。由于组合部件的过程很复杂,因此,这些部件的组合过程往往被“外部化”到一个称为建造者的对象里,建造者返还给客户端的是一个已经建造完毕的完整产品对象,而用户无需关心该对象所包含的属性以及他们的组装方式,这就是建造者模式的模式动机。

模式定义

建造者模式(Builder Pattern),将一个复杂对象的构建与他的表示分离,使得同样的构建过程可以创建不同的表示。

该模式是逐步创建一个复杂对象,允许用户只通过指定复杂对象的类型和内容就可以构建他们,而不需要知道他们内部的具体构建细节。又称为生成器模式

模式结构

  • Builder:抽象建造者
  • ConcreteBuilder:具体建造者
  • Director:指挥者
  • Product:产品角色

类图

NAME

时序图

NAME

代码示例

抽象建造者:

public interface PersonBuilder{
  void buildHead();
  void buildBody();
  void buildFoot();
  Persion buildPerson();
}

具体建造者 1:

public class ManBuilder implements PersonBuilder{
  Person person;
  public ManBuilder() {
    person = new Man();
  }
  public void buildBody() {
    person.setBody("set man's body");
  }
  public void buildFoot() {
    person.setFoot("set man's foot");
  }
  public void buildHead() {
    person.setHead("set man's head");
  }
  public Person buildPerson() {
    return person;
  }
}

具体建造者 2:

public class WomanBuilder implements PersonBuilder{
  Person person;
  public WomanBuilder() {
    person = new Woman();
  }
  public void buildBody() {
    person.setBody("set woman's body");
  }
  public void buildFoot() {
    person.setFoot("set woman's foot");
  }
  public void buildHead() {
    person.setHead("set woman's head");
  }
  public Person buildPerson() {
    return person;
  }
}

指挥者:

public class PersonDirector {
  public Person constructPerson(PersonBuilder pb){
    pb.buildHead();
    pb.buildBody();
    pb.buildFoot();
    return pb.buildPerson();
  }
}

产品:

public class Person {
  private String head;
  private String body;
  private String foot;
  
  public void setHead(String head){
    this.head = head;
  }
  public void setBody(String body){
    this.body = body;
  }
  public void setFoot(String foot){
    this.foot = foot;
  }
}

public class Man extends Person {
  public Man() {
    System.out.println("Start building Man");
  }
}

public class Woman extends Person {
  public Woman() {
    System.out.println("Start building Woman");
  }
}

用例:

public class Usage{
  public static void main(String[] args){
    PersonDirector pd = new PersonDirector();
    Person manPerson = pd.constructPerson(new ManBuilder());
    Person womanPerson = pd.constructPerson(new WomanBuilder());
  }
}

模式分析

抽象建造者类中定义了产品的创建方法和返回方法。

建造者模式的结构中还引入了一个指挥者类 Director,该类作用主要有两个:一方面将客户端与产品生产过程;另一方面负责整个产品的生产过程。

指挥者针对抽象建造者编程,客户端只需要提供具体建造者的类型,即可通过指挥者来调用具体建造者的方法,返回一个完整的产品对象。

在客户端代码中,则不需要关心产品具体的生产过程,只需要提供具体建造者类型来调用指挥者的创建方法。建造者模式将复杂对象的构建与对象的表现分离开来,这使得同样的构建过程可以创建出不同的表现。

实例

KFC 套餐

套餐看做是一个复杂对象,一般包括主食(汉堡、鸡肉卷等)和饮料(果汁、可乐等)等组成部分,服务员可以根据要求,逐步装配这些组成部分,构造一份完整的套餐,然后返回给顾客。

类图:

NAME

优点

  • 客户端不必知道产品内部的组成细节,将产品本身与产品的创建过程解耦,使得相同的创建过程(由父类创建者约束了创建该过程)可以创建不同的产品对象。
  • 每一个具体建造者都相对独立,与其他的具体建造者无关,因此可以很方便的替换具体建造者或者增加新的具体建造者,用户使用不同的具体建造者即可得到不同的产品对象。
  • **可以更加精细的控制产品的创建过程。**将复杂产品的的创建步骤分解在不同的方法中,使得创建过程更加清晰,也更方便使用程序来控制创建过程。
  • **增加新的具体创建者无需修改原有代码,指挥者针对抽象建造者编程。**系统扩展方便,符合开闭原则。

缺点

  • 建造者模式所创建的产品一般具有较多的共同点,其组成部分相似,如果产品之间的差异性很大,则不适合使用建造者模式。
  • 如果产品的内部变化复杂,可能需要定义很多具体建造者来实现这些变化,导致系统庞大。

适用场景

  • 需要生产的产品有复杂的内部结构,产品通常包含多个成员属性。
  • 需要生产的额产品,其属性相互依赖,需要制定生成顺序。
  • 对象的创建过程独立于创建该对象的类。在该模式中引入了指挥者类,将创建过程封装在指挥者类中,而不再建造者中。
  • 隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品。

模式扩展

建造者模式的简化:

  • 省略掉抽象建造角色:如果系统中只需要一个具体建造者的话。
  • 省略掉指挥者:在具体建造者只有一个的情况下,如果抽象建造者以及被省略掉,那么还可以省略掉指挥者,让建造者充当指挥者与建造者角色。

建造者模式与抽象工厂模式的比较:

  • 建造者返回一个组装好的完整产品,抽象工厂返回一系列相关的产品,这些产品位于不同的产品等级结构,构成一个产品族。
  • 抽象工厂中,客户端实例化工厂类,然后调用工厂方法获取所需产品对象;建造者中,客户端通过指挥类而不是调用建造者的相关方法,侧重于逐步构造一个复杂对象。
  • 如果将抽象工厂看做是“汽车配件生产工厂”,生产一个产品族的产品;建造者模式就是一个“汽车组装工厂”,通过对部件的组装返回一辆完整的汽车。

2.1.5 - CH05-创建型-单例

模式动机

对于系统中的某些类来说,只有一个实例很重要。比如,系统中可以有多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器和文件系统;一个系统只能有一个计时器或 ID 生成器。

方式是让类自身负责保存它的唯一实例,保证没有其他实例被创建,并且提供一个访问该实例的方法。

模式定义

单例模式(Singleton Pattern),确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。

单例模式的要点有三个:类只能有一个实例;必须自行创建这个实例;必须向整个系统提供这个实例。

模式结构

单例模式仅有一个角色:Singleton

类图

NAME

时序图

NAME

代码实例

模式分析

单例模式的目的是保证一个类仅有一个实例,并提供一个访问该实例的全局访问点。单例模式的角色只有一个,就是单例类-Singleton。单例类拥有一个私有构造函数,确保用户无法通过new关键字直接实例化它。该模式中包含一个静态私有成员变量与静态公共工厂方法,工厂方法负责检验实例是否存在并实例化自己,然后存储在静态成员变量中,以确保只有一个实例被创建。

在单例模式的实现过程中,需要注意一下要点:

  • 单例类的构造函数为私有;
  • 提供一个自身的静态私有成员变量;
  • 提供一个共有的静态工厂方法。

优点

  • 提供了对唯一实例的受控访问。因为单例类封装了它唯一的实例,所以他可以严格控制客户端怎样以及何时访问它,并为设计及开发团队提供了共享的概念。
  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,该模式可以提高系统性能。
  • 允许可变数目的实例。可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。

缺点

  • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 单例类的职责过重,在一定程度上违背了单一职责原则。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当产品角色,包含一些业务方法,将产品的创建和产品本身的功能融合到了一起。
  • 滥用单例将带来一些负面问题,比如,为了节省资源将数据库连接池对象设计成单例类,可能会导致共享该连接池对象的程序过多而出现连接池溢出;如果该对象长期不使用将被 GC 回收,导致对象状态丢失。

适用场景

  • 系统中只需要一个实例对象,如提供一个序列号生成器,或资源消耗太大而只能创建一个实例。
  • 客户端调用类的单个实例只允许适用一个公共访问点,不能通过其他途径访问该实例。
  • 系统中要求一个类只能有一个实例时才应当使用单例模式。

2.1.6 - CH06-结构型-适配器

模式动机

  • 在软件开发中采用类似于电源适配器的设计和编码技巧被称为适配器模式。
  • 通常,客户端可以通过目标类的接口访问他所提供的服务。有时,现有的类可以满足客户类的功能需要,但是它所提供的接口不一定是客户类所期望的,这可能是现有类中方法名与目标类中定义的方法名不一致等原因导致的。
  • 这时,现有的接口要转化为客户类期望的接口,这样保证了对现有类的重用。如果不进行这样的转化,客户类就不能利用现有类提供的功能,适配器用于完成这些转化。
  • 在适配器模式中可以定义一个包装类,包装不兼容接口的对象,这个包装类即为适配器(Adapter),他所包装的对象就是适配者(Adaptee),即被适配的类。
  • 适配器提供客户类需要的接口,适配器的实现就是把客户类的请求转化为对适配者的相应接口调用。也就是说:当客户类调用适配器的方法时,在适配器的内部将调用适配者的方法,而这个过程对客户端是透明的,客户端并不直接访问适配者类。因此,适配器可以使因为接口不兼容而不能交互的类完成兼容。即该模式的动机。

模式定义

适配器模式(Adapter Pattern),将一个接口转换为客户端期望的另一个接口,使接口不兼容的类可以在一起工作,别名为包装器(Wrapper)。该模式即可以作为类接口型模式,也可以作为对象结构型模式。

模式结构

包含如下角色:

  • Target:目标抽象类
  • Adapter:适配器类
  • Adaptee:适配者类
  • Client:客户类

类图

分为对象适配器类适配器两种实现:

对象适配器

NAME

类适配器

NAME

时序图

NAME

代码分析

对象适配器:

int main(int argc, char *argv[]){
  Adaptee * adaptee = new Adaptee();
  Target * target = new Adapter(adaptee);
  target->request();
  return 0;
}

优点

  • 将目标类与适配者类解耦,通过引入一个适配器类来重用现有的适配者类,而无需修改原有代码;
  • 增加了类的透明性和复用性,将具体的实现封装在适配者类中,对于客户端来说是透明的,而且提高了适配者的复用性。
  • 灵活性和扩展性很好,通过使用配置文件,可以方便的更换适配器,也可以在不修改原有代码的基础上增加的的适配器类,完全符合“开闭原则”。

类适配器模式的独特优点:

由于适配器类是适配者类的子类,因此可以在适配器类中置换一些适配者的方法,使得适配器的灵活性更强。

对象适配器模式的独特优点:

​ 一个对象适配器可以把多个不同的适配者适配到同一目标,也就是说,同一个适配器可以把适配者类和它的子类都适配到目标接口。

缺点

  • 类适配器的缺点:对于 Java、C# 等不支持多重继承的语言,一次只能适配一个适配者类,而且目标类只能为抽象类,不能为具体类,有一定局限性,不能将一个适配者类和其子类都适配到目标接口。
  • 对象适配器的缺点:对类适配器相比,要想置换适配者类的方法不方便。如果一定要置换掉适配者类的一个或多个方法,就只好先做一个适配者类的子类,将适配者类的方法置换掉,然后把这个子类当做真正的适配者进行适配,实现复杂。

适用场景

  • 需要使用现有类,而这些类的接口不符合系统的需要。
  • 想要建立一个可重复使用的类,用于与一些彼此之间没有太大关联的类,包括一些可能在将来引进的类一起工作。

模式扩展

默认适配器模式(Default Adapter Pattern),或称缺省适配器模式。当不需要全部实现接口提供的方法时,可先设计一个抽象类实现接口,并为该接口中每个方法提供一个默认实现(空方法),该抽象类的子类则可以有选择的覆盖父类的某些方法来实现需求。适用于一个接口不想使用其所有的方法的情况,因此也称为单接口适配器模式。

2.1.7 - CH07-结构型-桥接

模式动机

设想如果要绘制矩形、圆形、椭圆、正方形,至少需要四种形状类,但是如果绘制的图形具有不同的颜色,此时至少有以下两种设计方案:

  1. 为每一种形状都提供一套各种颜色的版本
  2. 根据实际需要对形状和颜色进行组合

对于有两个变化维度的系统,采用第二种方案进行设计则类的个数更少,系统扩展也更方便。方案二即为桥接模式,该模式将继承关系转换为关联关系,减少耦合和编码量。

模式定义

桥接模式(Bridge Pattern),将抽象部分与它的实现部分分离,使他们都可以独立变化。又称为柄体模式(Handle and Body)接口模式(Interface)

模式结构

包含四种角色:

  • Abstraction:抽象类
  • RefinedAbstraction:扩充抽象类
  • Implementor:实现类接口
  • ConcreteImplementor:具体实现类

类图

NAME

时序图

NAME

代码分析

int main(int argc, char argv[]){
  Implementor * pImp = new ConcreteImplementorA();
  Abstraction * pa = new RefinedAbstraction();
  pa->operation();
  
  Abstraction * pb = new RefinedAbstraction(new ConcreteImplementorB());
  pb->operation();
  ...
}

模式分析

理解桥接模式,重点需要理解如何将抽象化(Abstraction)和实现化(Implementation)解耦,使得二者可以独立的变化。

  1. 抽象化:抽象化就是忽略一些信息,把不同的实体当做同样的实体对待,将对象的共同性质抽取出来形成类的过程即为抽象化;
  2. 解耦:解耦就是将抽象化和实现化之间的耦合解脱开,或者将他们之间的强关联转换成弱关联,将两个角色之间的继承关系改为关联关系。桥接模式中的解耦,是指在一个软件系统的抽象化和实现化之间使用关联关系(组合、聚合),而不是继承关系,从而使两者可以相互独立的变化,也就是桥接模式的动机。

实例

如果需要开发一个跨平台的视频播放器,可以在不同操作系统平台上播放多种格式的视频文件,常见的视频格式包括 MPEG、RMVB、AVI、WMV 等。

优点

  • 分离抽象接口及其实现部分。
  • 桥接模式有时类似于多继承方案,但是多继承方案违背了类的单一职责原则(一个类只有一个变化的原因),复用性较差,而且多继承中类的个数非常庞大,桥接则是更好的方法。
  • 提高了系统的可扩充性,在两个变化维度任意扩展一个维度,都不需要修改原有的系统。
  • 实现细节对客户透明,可以对用户隐藏实现细节。

缺点

  • 桥接模式的引入会增加系统的理解与设计难度,由于聚合关联关系建立在抽象层,要求开发者针对抽象进行设计与编程。
  • 要求正确识别出系统中两个独立变化的维度,使用范围有一定局限性。

适用场景

  • 如果一个系统需要在构件的抽象化角色和具体化角色之间增加更多灵活性,避免在两个层次之间建立静态继承关系,通过桥接模式可以使他们在抽象层建立一个关联关系。
  • 抽象化角色和实现化角色可以以继承的方式独立扩展而互不影响,在程序运行时可以动态将一个抽象化子类的对象和一个实现化子类的对象进行组合,即系统需要对抽象化角色和实现化角色进行动态耦合。
  • 一个类存在两个独立变化的维度,且这两个维度都需要独立扩展。
  • 虽然在系统中使用继承是没有问题的,但是由于抽象化角色和实现化角色需要独立变化,设计要求独立管理这两者。
  • 对于那些不希望使用继承或因为多层继承导致系统类的个数急剧增加的系统,桥接模式尤为适用。

模式扩展

适配器与桥接模式联用:

​ 桥接模式和适配器模式用于设计的不同阶段。桥接模式用于系统的初步设计,对于存在两个独立变化维度的类可以将其分为抽象化和实现化两个角色,使他们可以进行分别变化;在初步设计完成后,当发现系统与已有类无法协同工作时,可以使用适配器模式。有时也需要在设计初期使用适配器模式,尤其是在那些涉及大量第三方应用接口的时候。

2.1.8 - CH08-结构型-装饰器

模式动机

一般有两种方式可以实现给一个类或对象增加行为:

  1. 继承机制:通过继承一个类,使子类在拥有自身方法的同时拥有父类的方法。但是这种方式是静态的,用户不能控制增加行为的方式和时机。
  2. 关联机制:即将一个类的对象嵌入到另一个对象中,由另一个对象来决定是否调用嵌入对象的行为以便扩展自己的行为,这个嵌入的被称为装饰器。

装饰器模式以客户透明的方式动态给一个对象附加上更多的责任,客户端并不会觉得对象在装饰前后有什么不同。可以在不创建更多子类的情况下扩展对象的功能。即该模式的动机。

模式定义

装饰器模式(Decorator Pattern),动态给对象增加一些额外的职责(Responsibility),就增加对象功能来说,装饰模式比创建子类的实现更为灵活。或称为“包装器”,与适配器别名相同但应用场景不同,或称“油漆工模式”。

模式结构

包含四种角色:

  • Component:抽象构件
  • ConcreteComponent:具体构件
  • Decorator:抽象装饰类
  • ConcreteDecorator:具体装饰类

类图

NAME

时序图

NAME

代码分析

模式分析

  • 与继承关系相比,关联关系的主要优势在于不会破坏的类的封装性,而且继承是一种耦合度较大的静态关系,无法在程序运行时动态扩展。在软件开发阶段,关联关系虽然不会比继承关系减少编码量,但是到了维护阶段,由于关联关系具有较好的松耦合性,因此系统会更易维护。而关联关系的缺点则是会比继承关系要多创建更多对象。
  • 使用装饰器模式来实现扩展比继承更加灵活,它以对客户透明的方式动态的给一个对象附加更多的责任。该模式可以在不创建更多子类的情况下,扩展对象功能。

实例

变形金刚

变形金刚在变型之前是一辆汽车,它可以在陆地上移动。变型之后除了能够移动外还可以说话;或者变成飞机飞行。

类图:

NAME

时序图:

NAME

优点

  • 装饰模式与继承关系的目的都是要扩展对象的功能,但是装饰器比继承更为灵活。
  • 可以通过动态的方式来扩展一个对象的功能,通过配置文件在运行时选择不同的装饰器,从而实现不同的行为。
  • 通过使用不同的具体装饰类已经这些装饰类的排列组合,可以创造出很多不同行为的组合。或者使用多个不同的具体装饰类装饰同一个对象,得到功能更对强大的对象。
  • 具体构件类与具体装饰类可以独立变化,用户可以根据需要增加新的具体构建类和具体装饰类,在使用时再对其进行组合,原有代码无需改变,符合开闭原则。

缺点

  • 使用装饰模式进行系统设计时将产生很多小对象,这些对象的区别在于他们之间的相互连接方式有所不同,而不是他们的类或属性值有所不同,同时还将产生很多具体装饰类。这些装饰类和小对象的产生将增加系统的复杂性,加大学习与理解难度。
  • 这种比继承机制更加灵活的特性,同时也意味着装饰模式比继承模式更易出错,排查也更加困难,对于多次装饰的对象,调试时寻找试错可能需要逐级排查,较为繁琐。

适用场景

  • 在不影响其它对象的情况下,以动态、透明的方式给单个对象增加职责。
  • 需要动态给一个对象增加功能,这些功能也可以动态的被撤销。
  • 当不能采用继承的方式对系统中进行扩从或采用继承不利于系统扩展与维护时。有两种情况不适合使用继承:系统中存在大量独立的扩展,为支持每一种组合将产生大量的子类;活类的定义为 final,不能继承。

模式扩展

装饰器模式简化需要注意的问题:

  • 一个装饰类的接口必须与被装饰类的接口保持相同,对于客户端来说,对象在被装饰前后都可以一致对待。
  • 尽量保持具体构件类 Component 作为一个“轻”类,不要把太多的逻辑和状态放在具体构件类中,可以通过装饰类来完成这些工作

对该模式进行扩展时:如果只有一个具体构件类而没有抽象构件类,那么抽象装饰类可以作为具体构件类的直接子类。

2.1.9 - CH09-结构型-外观

模式动机

模式定义

外观模式(Facade Pattern),外部与子系统的通信必须通过一个统一的外观对象进行,为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。又称门面模式

模式结构

包含两种角色:

  • Facade:外观角色
  • SubSystem:子系统角色

类图

NAME

时序图

NAME

代码分析

模式分析

  • 根据单一职责原则,在软件中将一个系统划分为若干个子系统有利于降低整个系统的复杂性,一个最常见的目标是使子系统之间的通信和相互依赖关系达到最小,达到该目标的方式之一就是引入一个外观对象,来为子系统提供一个简单而单一的入口。
  • 外观模式也是迪米特法则的体现,通过引入一个新的外观类可以降低原有系统的复杂度,同时降低客户类与子系统类的耦合度。
  • 该模式要求,一个子系统的外部与其内部的通信通过一个统一的外观对象进行,外观类将客户端与子系统的内部复杂度分隔开,客户端则只需要与外观类交互,而不需要和子系统内部的诸多对象交互。
  • 该模式的目的在于降低系统的复杂度。
  • 该模式很大程度上提高了客户端使用上的便捷性,使得客户端无需关心子系统的内部细节。

优点

  • 对客户端屏蔽子系统组件,减少客户处理的对象数目,并使得子系统用起来更容易。
  • 实现客户端与子系统的松耦合,使子系统的组件变化不会影响到客户类,只需要调整外观类即可。
  • 降低大型系统中的编译依赖性,并简化了系统在不同平台之间的迁移过程,可以以一个更小粒度(子系统)修改、编译软件。
  • 只是提供了一个统一访问子系统的统一入口,并不影响用户直接使用子系统类。

缺点

  • 不能很好的限制客户使用子系统类,如果对客户访问子系统类做太多的限制则减少了可变性和灵活性。
  • 在不引入抽象外观类的情况下,增加新的子系统可能要修改外观类或客户端的代码,违背了开闭原则。

适用场景

  • 当要为复杂子系统提供一个简单接口时可以考虑该模式。
  • 当客户与多个子系统之间存在很大的依赖性。引入外观类将子系统与客户以及其他子系统解耦,可以提高子系统的独立性和可移植性。
  • 在层次化结构中,可以使用外观模式定义系统中的每一层接口,曾与曾之间不直接产生联系,而通过外观类建立联系,降低层之间的耦合度。

模式扩展

  • 一个系统有多个外观类

    通常只有一个外观类,同时只有一个实例,即它是一个单例类。同时也可以定义多个外观类,分别于不同的特定子系统交互。

  • 不要通过外观类为子系统增加新的行为

  • 外观模式与迪米特法则

    外观类充当了客户与子系统之间的第三者,降低客户与系统的耦合度。

  • 引入抽象外观类

    该模式最大的缺点在于违背了开闭原则。当增减子系统时可以通过引入抽象外观类来解决该问题,客户端则针对抽象外观类编程。以增减具体外观类的方式来支持子系统的变更。

2.1.10 - CH10-结构型-享元

模式动机

OOP 可以很好的解决一些灵活性和扩展性问题,但同时要增加对象的个数。当对象数过多则会导致运行代价过高,带来性能问题。

  • 享元模式正式为了解决这类问题。该模式通过共享技术实现相同或相似对象的重用。
  • 在该模式中可以共享的相同内容称为内部状态(Intrinsic State),而那些需要通过外部环境来配置的不能共享的内容称为外部状态(Extrinsic State)。因为区分了内外状态,可以通过设置不同的外部状态使得相同对象可以具有一些不同的特征,而相同的内部状态则可以共享。
  • 该模式中通常会出现工厂模式,需要创建一个享元工厂来负责维护一个**享元池(Flyweight Pool)**用于存储具有相同内部状态的享元对象。
  • 该模式中共享的享元对象的内部状态、外部状态需要通过环境来配置。在实际使用中,能够共享的内部状态是有限的,因此享元对象一般都设计为较小的对象,所包含的内部状态较少,也称为细粒度对象。该模式的目的就是使用共享技术来实现大量细粒度对象的复用。

模式定义

享元模式(Flyweight Pattern),运用共享技术有效的支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。由于该模式要求能够共享的对象必须是细粒度对象,因此也称为轻量级模式

模式结构

该模式包含四种角色:

  • Flyweight:抽象享元类
  • ConcreteFlyweight:具体享元类
  • UnsharedConcreteFlyweight:非共享具体享元类
  • FlyweightFactory:享元工厂类

类图

NAME

时序图

NAME

代码分析

模式分析

该模式是一种考虑系统性能的设计模式,使用该模式以节约内存空间,提供系统性能。

享元模式的核心在于享元工厂类,它的作用在于提供一个用于存储享元对象的享元池,用户需要对象时,首先从享元池中取,如果不存在则创建一个该具体享元类的实例,存入享元池并返回给客户。

该模式以共享的方式高效的支持大量的细粒度对象,享元对象能够做到共享的关键是区分内部状态和外部状态:

  • 内部状态:是存储在享元对象内部并且不会随着环境改变而改变的状态。
  • 外部状态:是随环境而改变的、不可共享的状态。享元对象的外部状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候在传入到享元对象内部。外部状态相互之间是独立的。

优点

  • 可以极大减少内存中对象的数量,使得相同对象或者相似对象在内存中只保存一份。
  • 外部状态相互独立,而且不会影响其内部状体,从而使得享元对象可以在不同的环境中被共享。

缺点

  • 该模式使得系统更加复杂,需要分离内部状态和外部状态,使程序逻辑复杂。
  • 为了使对象可以共享,需要将对象的状态外部化,而读取外部状态会使运行时间变长。

适用场景

  • 一个系统有大量相同或相似对象,由于这类对象的大量使用,造成内存大量使用。
  • 对象的大部分状态可以外部化,可以将这些外部状态传入对象中。
  • 使用该模式需要维护一个存储享元对象的享元池,而这需要消耗资源,因此,应当在多次重复使用享元对象时才值得使用该模式。

模式应用

该模式大量应用于编辑器软件,如在一个文档中多次使用相同的图片等。

模式扩展

单纯享元模式和复合享元模式:

  • 单纯享元模式:所有的享元对象都是可以共享的,即所有抽象享元类的子类都可共享,不存在非共享具体享元类。
  • 复合享元模式:将一些单纯享元使用组合模式加以组合,可以形成复合享元对象,这样的复合享元对象本身不能共享,但是可以分解成单纯享元对象,然后进行共享。

与其他模式联用:

  • 在享元工厂类中通常提供一个静态工厂方法用于返回享元对象,使用简单工厂模式来生成享元对象。
  • 在一个系统中,通常只有唯一一个享元工厂,因此享元工厂类可以使用单例模式进行设计。
  • 可以结合组合模式形成复合享元模式,统一对享元对象设置外部状态。

2.1.11 - CH11-结构型-代理

模式动机

某些情况下,一个客户不想或者不能直接引用一个对象,此时可以使用一个称为代理的第三者来实现间接引用。代理对象可以在客户端和目标对象之间起到中介作用,并且可以通过代理对象来去掉客户不能看到的内容、服务或资源,或添加额外的功能。

通过引入一个新的对象(小图片、远程代理 对象)来实现对真实对象的操作,或者将新的对象作为真实对象的一个替身,这种实现机制即为代理模式,通过引入代理对象来间接访问一个对象

模式定义

代理模式(Proxy Pattern),给某一个对象提供一个代理,并由代理对象控制对源对象的引用。或称为 Surrogate

模式结构

共包含三种角色:

  • Subject:抽象主题角色
  • Proxy:代理主题角色
  • RealSubject:真实主题角色

类图

NAME

时序图

NAME

优点

  • 能够协调调用者和被调用者,一定程度上降低了系统耦合度。
  • 远程代理使得客户端可以访问在远程机器上的对象,远程机器可能具有更好的计算性能与处理速度,可以快速响应并处理客户端请求。
  • 虚拟代理通过使用一个小对象来代表一个大对象,可以减少系统资源的消耗,优化系统以提升速度。
  • 保护代理可以控制真是对象的使用权限。

缺点

  • 由于在客户端和真实对象之间增加了代理,因此,有些类型的代理模式(如远程)可能会造成请求的处理速度过慢。
  • 实现代理模式需要额外的工作,有些实现则比较复杂。

适用场景

  • 远程代理(Remote):为一个处于不同地址空间的对象提供一个本地代理对象。
  • 虚拟代理(Virtual):如果需要创建一个资源消耗过大的对象,先创建一个消耗相对较小的对象来表示,真实对象只在需要时在会被真正创建。
  • Copy-To-Write 代理:是虚拟代理的一种,把复制(克隆)操作 延迟到只有在客户端真正需要时才执行。对象的深克隆是一个开销较大的工作,此方式可以使该操作延迟,需要时才执行。
  • 保护代理(Protect or Access):控制一个对象的访问,可以给不同的用户提供不同级别的使用权限。
  • 缓冲代理(Cache):为某一个目标操作的结果提供临时的存储空间,以使多个客户端可以共享这些结果。
  • 防火墙代理(Firewall):保护目标不让恶意用户接触。
  • 同步化代理(Synchronizaiton):使几个用户能够同时使用一个对象而没有冲突。
  • 智能引用代理(Smart Reference):当一个对象被引用时,提供一些额外操作,比如记录对象的调用次数。

模式应用

EJB、Web Service等分布式技术都是代理模式的应用。

2.1.12 - CH12-行为型-命令

行为型模式概述

行为型模式(Behavioral Pattern)是对在不同的对象之间划分职责和算法的抽象。

该模式不仅仅关注类和对象的结构,而且关注他们之间的相互作用

通过该模式,可以更加清晰地划分类与对象的职责,并研究系统在运行时实例对象之间的交互。在系统运行时,对象并不孤立,他们可以通过相互通信与协作完成某些复杂功能,一个对象在运行时也将影响到其他对象的运行。

该类模式又分为类行为模式对象行为模式

  • 类行为模式:使用继承关系在几个子类之间分配行为,通过多态方式分配父类与子类的职责。
  • 对象行为模式:使用对象的聚合关联关系来分分配行为,主要是通过对象关联等方式类分配两个或多个类的职责。根据合成复用原则,系统中要尽量通过关联关系来取代继承关系,因此绝大多数的行为设计模式都属于对象行为型模式。

命令模式

模式动机

软件设计中,经常要向对象发送请求,但是并不知道对象的接收者是谁,也不知道被请求的操作是哪个,只需要在运行时指定具体的请求接收者即可,此时,可以使用命令模式来进行设计,使得请求发送者和接收者之间接触耦合,增加对象调用之间的灵活性。

命令模式可以对发送者和接收者完全解耦,发送者与接收者之间没有直接引用关系,发送请求的对象只需要知道如何发送请求,而不必知道如何完成请求。这就是命令模式的模式动机。

模式定义

命令模式(Command Pattern):将一个请求封装为一个对象,从而使我们可以用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。属于对象行为型模式,别名为动作模式(Action)事务模式(Transaction)

模式结构

包含五种角色:

  • Command:抽象命令类
  • ConcreteCommand:具体命令类
  • Invoker:调用者
  • Reciver:接收者
  • Client:客户端

类图

NAME

时序图

NAME

代码示例

模式分析

命令模式的本质是对命令进行封装,将发送命令的责任和执行命令的责任分隔开。

  • 每一个命令都是一个操作:请求方发出请求,要求执行一个操作;接收方收到请求,并执行操作。
  • 命令模式运行请求方和接收方独立开来,使得请求方不必知道接收方的接口,更不必知道请求是如何被接收的,以及操作是否被执行、合适被执行,以及如何执行。
  • 命令模式使请求本身称为一个对象,这个对象和其他对象一样可以被存储和传递。
  • 命令模式的关键在于引入了抽象命令接口,并且发送者针对抽象命令接口编程,只有实现了抽象命令接口的具体命令才能与接收者相互关联。

优点

  • 降低系统的耦合度
  • 新的命令可以很容易地加入到系统
  • 可以比较容易的设计一个命令队列和宏命令(组合命令)
  • 可以方便的实现对请求的 Undo 和 Redo 操作

缺点

  • 可能会导致某些系统有过多的具体命令类。因为针对每一个命令都需要设计一个具体命令类,因此某些系统可能会需要大量命令类,这将影响命令模式的使用。

适用场景

  • 系统需要将请求调用者和接收者解耦,使得调用者和接收者不直接交互
  • 需要在不同的时间指定请求、将请求排队、执行
  • 系统需要支持命令的撤销和恢复操作
  • 系统需要将一组操作组合在一起,即自持宏命令

模式扩展

  • 宏命令又称组合命令,它是命令模式和组合模式联用的产物。
  • 宏命令也是一个具体命令,不过他包含了对其他命令对象的引用。在调用宏命令的execute()方法时,将会递归调用它所包含的每个成员命令的execute()方法。
  • 一个宏命令的成员对象可以是一个简单命令也可以是另外一个宏命令。
  • 执行一个宏命令将执行多个具体命令,从而实现对命令的批处理。

2.1.13 - CH13-行为型-中介者

模式动机

  • 在用户与用户直接聊天的设计方案中,用户对象之间存在很强的关联性,将导致系统出现如下问题:
    • 系统结构复杂:对象之间存在大量的相互关联和调用,如有一个对象发生变化,则需要跟踪那些与该对象关联的其他所有对象,并进行适当处理。
    • 对象可用性差:由于一个对象和其他对象具有很强的关联,如没有其他对象的支持,一个对象很难被另一个系统或模块复用,这些对象表现出来更像一个不可分割的整体,职责较为混乱。
    • 系统扩展性低:增加一个新的对象需要在原有对象上增加引用,增加新的引用关系也需要调整原有对象,系统耦合度高,对象操作不灵活,扩展性差。
  • 在面向对象的软件设计与开发过程中,根据“单一职责原则”,应该尽量将对象细化,使其只负责或呈现大一的职责。
  • 对于一个模块,可能有很多对象构成,而且这些对象之间可能存在相互的引用,为了减少对象两两之间复杂的引用关系,使之成为一个松耦合的系统,我们需要使用中介者模式,这就是该模式的动机。

模式定义

中介者模式(Mediator Pattern):用一个中介对象来封装一系列对象交互,中介者使各个对象不需要直接的相互引用,从而使其耦合松散,而且可以独立的改变他们之间的交互。中介者模式又称为调停者模式,是一种对象行为型模式。

模式结构

包含四种角色:

  • Mediator:抽象中介者
  • ConcreteMediator:具体中介者
  • Colleague:抽象同事类
  • ConcreteColleague:具体同事类

类图

NAME

时序图

NAME

模式分析

中介者模式可以使对象之间的关系数量急剧减少。

中介者承担两方面的职责:

  • 中转作用(结构性):通过中介者提供的中转作用,各个同事对象之间就不需要再进行显式引用,当需要和其他同事进行通信时,通过中介者即可。该中转作用属于中介者在结构上的支持。
  • 协调作用(行为性):中介者可以更进一步的对同事之间的关系进行封装,同事可以一致的和中介者进行交互,而不需要指明中介者需要怎么做,中介者根据封装在自身内部的协调逻辑,对同事的请求进行进一步处理,将同事成员之间的关系行为进行分离和封装。该协调作用属于中介者在行为上的支持。

优点

  • 简化了对象之间的交互
  • 将各同事解耦
  • 减少子类生成
  • 可以简化各同事类的设计和实现

缺点

在具体中介类中包含类同事之间的交互细节,可能会导致具体中介类非常复杂,使系统难以维护。

适用场景

  • 系统中对象之间存在复杂的引用关系,产生的相互依赖关系结构混乱且难以理解。
  • 一个对象由于引用了其他很多对象并且直接和这些对象通信,导致难以复用该对象。
  • 想通过一个中间类来疯转多个类中的行为,而又不想生成太多的子类。
  • 交互的公共行为,如果需要改变行为可以增加新的中介者类。

模式应用

MVC 架构中的控制器:Controller 作为一个中介者,它负责控制试图对象 View 和模型对象 Model 之间的交互。

2.1.14 - CH14-行为型-观察者

模式动机

建立一种对象与对象之间的依赖关系,一个对象改变时将自动通知其他对象,其他对象将作出相应反应。在此,发生改变的对象成为观察目标,被通知的对象成为观察者,一个观察目标可以对应多个观察者,而且这些观察者之间没有相互联系,可以根据需要增加或删除观察者,使得系统更易于扩展。这便是该模式的动机。

模式定义

观察者模式(Observer Pattern):定义对象的一种一对多关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。或称为发布-订阅(Publish/Subscribe)模式、模型-试图(Model/View)模式、源-监听(Source/Listener)模式、从属(Dependents)模式

模式结构

包含 4 中角色:

  • Subject:目标
  • ConcreteSubject:具体目标
  • Observer:观察者
  • ConcreteObserver:具体观察者

类图

NAME

时序图

NAME

模式分析

  • 管擦者模式描述了如何建立对象与对象之间的依赖关系,如何构造满足这种需求的系统。
  • 该模式中的关键对象是观察目标和管擦者,一个目标可以有任意数目的、与之相依赖的观察者,一旦目标的状态发生改变,所有的观察者都将得到通知。
  • 作为多这个通知的响应,每个观察者都将更新自己的状态,以与目标状态同步,这种交互也被称为发布-订阅。目标是通知的发布者,它发出通知时并不知道谁是它的观察者,可以有任意数量的观察者订阅它并接收通知。

优点

  • 该模式可以实现表示层和数据逻辑层的分离,并定义了稳定的消息更新传递机制,抽象了更新接口,使得可以有各种不同的表示层作为具体观察者角色。
  • 该模式在观察目标和观察者之间建立了一个抽象的耦合。
  • 观察者模式支持广播通信。
  • 观察者模式符合“开闭原则”。

缺点

  • 如果一个目标对象有很多直接和间接的观察者的话,将所有的观察者都通知到将会花费很多时间。
  • 如果在观察者和目标之间有循环依赖的话,目标会触发他们之间进行循环调用,导致系统崩溃。
  • 该模式没有相应的机制让观察者知道相应的观察目标是怎样发生变化的,而仅仅只是知道目标发生了变化。

适用场景

  • 一个抽象模型有两个方面,其中一个方面依赖于另一个方面。将这些方面封装在独立的对象中,可以使他们各自独立的改变和复用。
  • 一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少个对象发生改变,可以降低对象之间的耦合度。
  • 一个对象必须通知其他对象,而并不知道这些对象是谁。
  • 需要在系统中创建一个触发链,A 对象的行为将影响 B 对象, B 对象的行为将会影响 C 对象…等等,可以使用该模式创建一种链式触发机制。

2.1.15 - CH15-行为型-状态

模式动机

  • 很多情况下,一个对象的行为取决于一个或多个动态变化的属性,这样的属性称为状态,这样的对象称为有状态(stateful)对象,这样的对象状态是从事先定义好的一系列值中取出的。当一个这样的对象与外部事件产生互动时,其内部状态就会改变,从而使得系统行为也随之改变。
  • 在 UML 中可以使用状态图来描述对象状态的变化。

模式定义

状态模式(State Pattern):允许一个对象在其内部状态改变时改变它自身的行为,对象开起来似乎修改了它的类。其别名为状态对象(Objects for states)。

模式结构

包含 3 种角色:

  • Context:环境类
  • State:抽象状态类
  • ConcreteState:具体状态类

类图

NAME

时序图

NAME

模式分析

  • 状态模式描述了对象的状态变化以及对象如何在每一种状态下表现出不同的行为。
  • 状态模式的关键是引入了一个抽象类来专门表示对象的状态,这个类称为抽象状态类,而对象的每一种具体状态类都继承自该类,并在不同具体状态类中实现了不同状态的行为,以及不同状态间的转换。

需要理解环境类与抽象状态类的作用:

  • 环境类实际上就是拥有状态的对象,有时候可以充当状态管理器(State Manager)的角色,可以在环境类中对状态进行切换操作。

  • 抽象状态类可以是抽象类,也可以是接口,不同状态类就是继承这个父类的不同子类,状态类的产生是由于环境类存在多个状态,同时还满足两个条件:

    • 这些状态需要经常切换
    • 不同状态下,对象的行为不同

    因此,可以将不同对象下的行为单独提取出来封装在具体的状态类中,使得环境类对象在其内部状态改变时可以改变它的行为,对象看起来似乎修改了它的类,实际上是由于切换到不同的状态类实现的。

  • 由于环境类可以设置为任一具体状态类,因此它针对抽象状态类进行编程,在程序运行时可以将任一具体状态类的对象设置到环境类中,从而使得环境类可以改变内部状态,并且改变行为。

优点

  • 封装了转换规则
  • 枚举可能的状态,在枚举状态之前需要确定状态种类
  • 将所有与某个状态有关的行为放到一个类中,并且可以方便的增加新的状态,只需要改变对象状态即可改变对象行为。
  • 允许状态转换逻辑与状态对象合成一体,而不是一个巨大的条件语句块。
  • 可以让多个环境对象共现一个状态对象,从而减少系统中对象的个数。

缺点

  • 必然增加系统类和对象的个数
  • 结构与实现比较复杂,使用不当将导致结构与代码混乱
  • 对“开闭原则”的支持并不是很好,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态装换的源代码,否则无法切换到新增状态;修改某个状态类的行为也需要修改对应类的源代码。

适用场景

  • 对象的行为依赖于其状态(属性),并且需要根据它的状态改变而改变相关行为。
  • 代码中包含大量与对象状态有关的条件语句,这些条件语句的出现,会导致代码的可维护性和灵活性变差,不能方便的增加和删除状态,使客户类与类库之间的耦合增强。在这些条件语句中包含了对象的行为,而且这些条件对应于对象的各种状态。

2.1.16 - CH16-行为型-策略

模式动机

  • 完成一项任务,往往可以有多种不同的方式,每种方式称为一个策略,我们可以根据环境、条件的不同选择不同的策略来完成该项任务。
  • 在软件开发中也常常遇到类似的情况,实现某一功能有多个途径,此时可以使用一种设计模式来使得系统可以灵活的选择解决途径,也能够方便的增加新的解决途径。
  • 在软件系统中,有许多算法可以实现某一功能,如查找、排序等,一种常用的方法是硬编码在一个类中,如需要提供多种查询算法,可以将这些算法写到一个类中,在该类中提供多个方法,每个方法对应一种算法;或者将所有的算法封装在一个方法中,通过if-else语句来进行选择。这些方法都可以称为硬编码,如果需要增加一种新的算法,则需要修改封装算法类的源代码;修改、更换算法时,仍然需要修改客户端调用代码。这种方式使封装算法的类过于庞大、逻辑复杂,难以维护。
  • 除了提供专门的查找算法外,还可以在客户端程序中直接包含算法代码,这种做法更不可取,将导致客户端程序庞大而难以维护,如果存在大量可供选择的代码则将家中问题的严重性。
  • 为了解决这个问题,可以定义一些独立的类来封装不同的算法,每一个类封装一个具体的算法,每一个封装算法的类我们都可以称之为策略(Strategy),为了保证这些策略的一致性,一般会使用一个抽象的策略类来做算法的定义,而具体每种算法则应对应于一个具体的策略类。

模式定义

策略模式(Strategy Pattern):定义一系列算法,将每一个算法封装起来,并让他们可以互相替换。该模式实现算法能够独立于使用它的客户端而独立变化,或称政策模式(Policy)

模式结构

包含 3 种角色:

  • Context:环境类
  • Strategy:抽象策略类
  • ConcreteStrategy:具体策略类

类图

NAME

时序图

NAME

模式分析

  • 策略模式是一个比较容易理解和使用的模式,就是对算法的封装,它把算法的责任和算法本身分割开,委派给不同的对象管理。策略模式通常把一个系列的算法封装到一系列的策略类里面,作为一个抽象策略类的子类。即:准备一组算法,并将每一个算法封装起来,使得他们可以互换。
  • 在该模式中,应当有客户端自己决定何时、选择哪个具体策略。
  • 该模式仅仅是封装算法,提供新算法插入到已有系统中,以及老算法从系统中移除。策略模式并不觉得在何时使用哪种算法,这些由客户端类决定。在一定程度上提升了灵活性,但是客户端需要理解所有具体策略之间的区别,以便选择合适的算法,这也是该模式的缺点,一定程度上增加了客户端的使用难度。

优点

  • 提供了对开闭原则的支持,用户可以在不修改原有系统的基础上选择、增减算法或行为。
  • 提供了管理相关算法族的办法。
  • 提供了可以替换继承关系的办法。
  • 可以避免使用多重条件语句。

缺点

  • 客户端必须知道所有的策略类,并自行决定使用哪个策略。
  • 将会产生很多策略类,可以通过使用享元模式在一定程度上减少对象的数量。

适用场景

  • 如果一个系统中有很多类,他们之间的区别仅在于他们的行为,这时使用策略模式就可以动态的让一个对象在许多行为中选择一个。
  • 系统需要动态地在几种算法中选择一种。
  • 如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句实现。
  • 不希望客户端知道复杂的、与算法相关的数据结构,在具体策略类中封装算法和相关的数据结构,提高算法的保密性与安全性。

2.2 - Scala 模式

2.2.1 - CH01-模式分类

在计算机编程世界,有多种不同的方式来构建一个解决方案以用于解决某些问题。然而,有些人会考虑是否存在一种正确的方式来实现特定的任务。答案是肯定的;通常总是会存在正确的方式,但是在软件开发领域,通常会存在多种方式来实现一个任务。存在一些将程序员引导向正确方案的因素,基于此,人们往往也能得到预期的结果。这些因素可以包含多种事物—实际使用的语言,算法,可执行文件类型,输出格式,代码结构等等。本书中我们将会选择使用 Scala 语言。当然,有很多种方式来使用 Scala,不过这里我们仅会关注“设计模式”部分。

本章中,我们将会介绍什么是设计模式以及他们存在的意义。我们将会贯穿多种不同类型的设计模式。本书的目的在于提供有用的实例,以帮助你在学习过程中能够轻松运行他们。因此,这里会提供一些环境搭建的细节。下面是我们要涉及的几个首要话题:

  • 什么是一个设计模式以及它们为什么会存在?
  • 主要类型的设计模式及其特性
  • 选择正确的设计模式
  • 搭建开发环境

最后一点可能与设计模式本书关联不大。不过正确的构建项目总归是一个好的想法,同时也能使未来的工作更加轻松。

设计模式

在我们深入 Scala 设计模式之前,需要解释一下它们实际的含义、存在的意义,以及为什么值得去精通它们。

软件是一个宽泛的主题,人们可以用它们来做数不尽的事。乍一看,所做的这些事的是完全不同的—游戏、网站、移动手机应用或用于不同产业的特殊系统。然而软件的构建构过程却有很多相似性。无论需要创建的软件为哪种类型,通常人们都需要解决相似的问题。比如,网站和游戏都需要访问数据库。同时贯穿整个时间,开发者们通过经验来学习如何为不同的任务来组织他们的代码。

设计模式的一个正式定义能够让你理解其在构件优雅高效关键中的实际价值。

The formal definition for design patterns

一个设计模式是一个用于解决反复出现的问题的可复用解决方案。它并非一段已完成的代码片段,而是作为一个模板,用于帮助解决一些特殊问题或一些列问题。

设计模式是软件开发社区通过很长的时间总结出的最佳实践。它们被认为能够帮助编写高效、可读、易测试、易扩展的代码。在某些情况下,它们可能是某些表达不够优雅的编程语言(比如 Java..)的目标实现的结果。这意味着大多特性丰富的语言可能甚至都不需要一个设计模式。Scala 就属于这种富特性语言,有些情况下,它会让一些设计模式的应用显得过时或者更加简洁。我们将会在本书中看到它是如何做到这一点的。

一个编程语言中的功能缺陷或优势同样决定了其是否能够实现相比其他语言更多的设计模式。

Scala 和设计模式

Scala 是一个混合型语言,它同时结合了面向对象和函数式编程。这不仅让他能够拥有一些经典的面向对象设计模式,同时提供了多种不同的方式来利用其特性以编写高效、可读、易测试、易扩展的代码。这种混合的性质可以使用更加清晰的技术让一些经典的设计模式变得过时,或者让不能成为可能。

设计模式的需要及其收益

每个人都需要设计模式并在编写代码之前需要深入了解他们。像我们之前提到的,它们能够帮助编写高效、可读、易测试、易扩展的代码。所有这些特性对于行业中的公司来说都是非常重要的。

尽管有些情况下更需要的是快速编写一个原型并落地,然而更常见的情况是软件的一部分代码需要不断的演变。可能你已经拥有一些对已存在的烂代码扩展经验,但无论如何,这是一项挑战并且会花费很长时间,甚至有时会感觉重写会相对简单。此外,这也可能会给系统引入新的 Bug。

代码可读性有些时候也是需要引起重视的。当然,尽管有些人使用了设计模式但仍然会让他的代码难以理解,但通常来说设计模式会带来一些帮助。大型系统通常是由很多人协同开发,每个人也必须知道到底发生了什么。同时,新加入到团队中的人员也能更简单快速的融入进来,如果他们是基于程序中写的很好的部分工作的话。

可测试性有助于在编写或扩展代码时避免开发者引入 Bug。有些情况下,代码可能会很糟或甚至没法测试。设计模式一般也能很好的解决这些问题。

虽然效率通常与算法相关,设计模式同样也能对其造成影响。比如一个对象,需要很长时间才能完成初始化,并且其实例会被应用的各个部分使用,但这是可以使用单例来代替的。你可以在本书的后续章节中看到更多正确的实例。

模式分类

软件开发是一个非常宽泛的话题,这也使得大量的事情可以通过编程来完成。每件事都是不同的,这导致对软件质量的需求也多种多样。所有这些事实导致了大量不同的设计模式被发明。而这又进一步被现有的具有不同功能和表现力水平的编程语言所促进。

本书关注于从 Scala 的视角来看设计模式。如前所述,Scala 是一个混合型语言。这导致一些非常有名的设计模式不再被需要—比如“null 对象”设计模式,可以简单的使用 Scala 中的Option替代。其他的设计模式同过不同的方式也变得可行—装饰器模式可以使用叠加特质实现。最终,一些新的设计模式变得可用,他们尤其适用于 Scala 这种编程语言—比如蛋糕模式、皮条客等。我们将关注所有这些模式,并通过 Scala 的丰富特性使我们的代码更加简洁,从而使这些模式变得更加清晰。

尽管有多种不同的设计模式,但他们都可以被划分为不同的主要分类:

  • 创建型
  • 结构型
  • 行为型
  • 函数式
  • Scala 特有设计模式

一些特定于 Scala 的模式可以被归类到最后一个组。他们均可以作为现有模式的补充或替代。他们是 Scala 独有的,并且利用了一些其他语言中没有的高级语言特性或简单特性的优势。

前三个分类包含了著名的 Gang of Four 设计模式。每本设计模式的书都会涵盖这些,因此我们也不例外。而剩余的部分,尽管它们仍能归类于前三类,但都特定于 Scala 和函数式编程语言。在后续的一些章节中,我们将解释这些分类的主要特点并介绍它们实际所属的设计模式。

创建型设计模式

创建型设计模式用于处理对象的创建机制。它们的目的是在当前的场景中以合适的方式创建对象实例。如果不使用这些模式,将会带来更多不必要的复杂性同时也需要更多的知识。创建型模式背后的思想主要有一下几点:

  • 具体类的知识封装
  • 隐藏实际的创建过程和对象组合的细节

本书中我们将关注如下几种创建型模式:

  • 抽象工厂
  • 工厂方法
  • 惰性初始化
  • 单例
  • 对象池
  • 建造者
  • 原型

下面的一些小节给出了这些模式的简要定义,并会在后续章节中进行深入分析。

抽象工厂

用于封装那些拥有通用主题的单个工厂。使用时,开发者为抽象工厂创建一个具体实现,像工厂模式的使用方式一样使用其方法来创建对象。可以认为是用来帮助创建类的又一层抽象(类-工程-抽象工厂)。

工厂方法

创建实例时无需显式指定实例所拥有的具体类—它会基于工厂在运行时决定。这些工厂可能包括操作系统、不同的数据类型或输入参数。它使开发者能够调用一个方法而不是调用一个具体的构造器。

惰性初始化

用于延迟一个对象的创建或求值过程,直到第一次需要使用它的时候。在 Scala 中它会变得更加简单,而不像 Java 这种 OO 语言。

单例

限制一个类只能创建一个实例。如果应用中的多个类都要使用这样的实例,将会返回同一个实例给所有使用者。这种模式可以通过 Scala 的语言特性轻松实现。

对象池

创建一个已经初始化并等待使用的对象池。一旦有人需要使用池中的对象则会立即返回,使用完成后手动或自动放回到池中。最常用的场景是数据库连接,创建代价较为昂贵,通常是一次创建后服务于不同的客户端请求。

建造者

用于那些拥有大量构造器参数的对象,否则开发者需要为多种不同的创建方式编写多种不同的辅助构造器。这不同于工厂模式,其目的是支持多态。很多种现代化的库都运用了这种模式,Scala 中实现这种模式也非常简便。

原型

这种模式支持在已有的对象实例使用clone()方法来创建对象。用于某些创建起来非常昂贵的特殊资源,或者不愿使用抽象工厂模式的时候。

结构型设计模式

用于在多种不同的实体间建立联系以构造大型的结构。定义了各个组件的组织方式,以灵活的使多个模块在一个大型系统中协作。结构型模式的主要特性如下:

  • 将多个对象的实现结合的组合使用
  • 通过多种不同的组件构建大型系统并维持一个高级别的灵活性

本书中将关注如下几种结构型模式:

  • 适配器
  • 装饰器
  • 桥接
  • 组合
  • 门面
  • 享元
  • 代理

适配器

该模式支持一个接口可以使用一个已存在的类的接口。接入一个客户端希望你的类暴露一个doWork()方法。你可能在其他类中已经有这样的实现了,但是方法的调用方式不同而且不兼容。或许需要更多的参数。或者是一些开发者不拥有修改权限的库。这时适配器可以用于包装这些功能并暴露需要的方法。适配器用于集成已存在的组件。在 Scala 中,可以通过隐式类来轻松实现适配器。

装饰器

适配器是子类化的轻量级替代方案。它支持开发者扩展一个对象的功能而不影响用一个类的其他实例。将已被扩展的类的对象包装到另一个扩展了该类的对象,并重写需要被修改功能的方法。这种模式在 Scala 中可以通过特质叠加来实现。

桥接

用于将一个抽象与其实现之间解耦,从而使各自的变化独立。多用于当一个类和它的功能会经常变化。这种模式使我们想起适配器,但不同在于适配器用于一些已存在的而且无法改变的东西,而桥接则用在构建的时候。桥接可以让我们避免向客户端暴露多种不同的具体类。当我们深入该主题的时候你可能会理解的更清晰,不过现在,假如我们想拥有一个FlieReader类,它要支持多种不同的平台。使用适配器可以避免该类需要为不同的平台提供不同的实现类。在 Scala 中,我们可以使用自类型(self-type)来实现桥接。

组合

组合是一个分区设计模式,表示一组对象能够被当做一个单独的对象处理。它支持开发者能够统一处理单个对象或组合,并使用不复杂的代码来构建复杂的层次结构。比如一个树结构,一个节点可以包含另一个节点,等等。

门面

门面的目的在于为客户端提供一个简单的接口来隐藏系统的复杂性和实现细节。这使代码更具可读性并使外部代码减少依赖。它像是系统的一个简化过的包装器,当然也可以与前面提到的其他模式结合使用。

享元

该模式通过在系统中共享对象来优化内存使用。对象需要包含尽可能多的数据。比如一个单词处理器,每个字符的图形表示与其他相同的字符进行共享,本地信息将会仅包含字符的位置,被在内部保存。

运用共享技术有效地支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。由于享元模式要求能够共享的对象必须是细粒度对象,因此它又称为轻量级模式。

代理

代理模式支持开发者对其他对象进行包装以提供一个接口。同时能够提供额外的功能,比如安全性或线程安全。该模式可以与享元模式结合使用,那些共享对象的引用可以包装在代理对象的内部。

行为类设计模式

基于对象之间特殊的相互作用方式增加他们之间的灵活性。创建类模式大多描述对象的创建,结构类模式大多用于描述静态结构,而行为模式则描述一个过程或流向。它使这个流程更简单且更易理解。

行为类模式的主要特性:

  • 描述一个过程或流向
  • 流程更加简单且易于理解
  • 完成那些由对象难于或不可能完成的任务

本书将关注的行为类模式:

  • 值对象
  • Null 对象
  • 策略
  • 命令
  • 职责链
  • 解释器
  • 迭代器
  • 中介
  • 备忘
  • 管擦者
  • 状态
  • 模板方法
  • 访问者

值对象

值对象是不可变的并且他们的相等性并不基于他们的标示符(identity),而是基于他们的字段相等性。可以被用作数据传输对象、颜色、货币金额、数字等等。他们的不变型使其多用于多线程编程。Scala 中鼓励不可变性,值对象也作为自然存在的事情(源生提供不可变型对象)。

Null 对象

Null 对象表示值并不存在并定义一个中性的行为。该技术使对 null 引用的检查不再必要并且使代码更简明。Scala 添加了一个可选值的概念,即 Option,可以替代该模式。

策略

该模式支持在运行时选择算法。它定义了一类能够替换的封装算法,给客户端提供一个通用的接口。算法的选择取决于应用运行时能够决定的因素。在 Scala 中,可以向方法传递一个函数作为参数,基于该函数将会执行一个不同的动作。

命令

将一个稍后会触发的动作的信息保存为一个对象,信息中包含了:

  • 方法名
  • 方法的拥有者
  • 参数值

然后客户端来决定调用者在何时执行哪个命令。该模式在 Scala 中可以通过传名参数这种语言特性实现。

职责链

请求的发送者与其接收者进行解耦。这种方式能够使多个对象来接收请求并且保持逻辑分离。接收者组成的链条对请求进行传递,尝试处理,否则传递给下一个接收者。可以变化为一个处理者可以将请求同时分发给多个不同的处理者。

类似于函数组合,在 Scala 中通过叠加特质实现。

解释器

该模式基于语言是否能够通过其静态语法来描述一个著名领域。它定义了每个语法规则的类以解释给定语言中的句子。这些类很可能代表层次,因为语法通常也是分层的。解释器可以用在不同的解析器,比如 SQL 或其他语言。

迭代器

用于当一个迭代器穿过容器并访问其对象时。用于将容器与执行在容器上的算法解耦。迭代器应该提供的是对聚合对象的元素的顺序访问,而不必暴露被迭代集合的内部表示。

中介

封装一个应用中不同类之间的通信。对象通过中介进行通信而不是直接相互作用,以减少他们之间的依赖并解耦。

备忘

提供将一个对象回滚到上一个状态的能力。通过三个对象实现:originator, caretaker, memento。originator 是初始状态,caretaker 会修改 originator,memento 包含 originator 返回的状态。originator 知道如何处理一个 memento 以便存储它的上一个状态。

观察者

支持创建一个发布、订阅系统。有一个特殊的对象称为主题,当它的状态发生任何改变时会通知所有的观察者。在 GUI 工具包中处理事件时比较常用。与响应式编程也相关,比如 Akka。

状态

类似于策略模式,使用一个状态对象封装同一个对象的不同行为。它通过避免大量的条件语句来提高代码的可读性和易维护性。

模板方法

在一个方法中定义一个算法的纲要,然后传递一些实际的步骤到子类。支持开发者在不修改算法结构的前提下修改其中的一些步骤。可以是一个抽象类中的方法并调用其他的抽象方法,这些方法在子类中进行实现。

访问者

表示一个执行在对象结构的元素上的操作。支持开发者在不修改原始类的情况下定义一个新的操作。

Scala 中通过传递函数到方法来实现。

函数式模式

我们将从 Scala 的视角来研究前面所提到的设计模式。这意味着它们会看起来与在其他语言中有所不同,但他们并没有专门的被设计为用于函数式编程。函数式编程比面向对象编程拥有更强的表现力。它拥有其自己的设计模式来帮助开发者活的更轻松。我们将会关注:

  • Monoids(幺半群、幺元)
  • Monads(单子)
  • Functors(函子)

在我们了解过 Scala 的一些函数式编程概念之后将会贯穿这些,我们将会提高一些 Scala 世界中比较有趣的设计模式。

Monoids

Monoid 是一个来自数学的概念。我们会在本书的后续部分深入分析需要理解它的更多理论。现在则只需要记住“Monoid 是一个拥有可结合的二元运算和一个单位元的代数结构”。一些你需要知道的关键字:

  • 可结合的二元运算:(a+b)+c = a+(b+c)
  • 单位元:a+i = i+a = a,这里 i 则为单位元,比如整数中的 0,字符串中的空串““

重要的是 Monoid 为我们提供了一种可能性:使用通用的方式来处理不同类型的值。它支持我们将成对的(两两的)操作适用于序列;结合律让并行化称为可能,单位元元素让我们知道如何处理空列表。Monoid 让我们更易于描述和实现集合。

Monads

在函数式编程中,Monad 是用于将计算描述为步骤序列的结构。Monad 可用于构建流水线,在一切都是不可变的语言中以更清洁的方式添加带有副作用的操作,实现组合。定义可能听起来晦涩难懂,但是通过几个句子来解释 Monad 恐怕是难以实现的。在本书的后续部分,我们会继续关注它并尝试不使用复杂的数学理论来使其更加清晰。我们将会尝试展示为什么 Monad 有用,他们又带来了什么帮助,直到开发者能够很好的理解他们。

Functors

Functor 来自范畴论,和 Monad 一样,正确的解释它恐怕要花点时间。我们将会在本书后续部分继续关注它,现在你可以理解为 Functor 能够支持我们将类型A => B的一个函数提升为类型F[A] => F[B]的函数。

Scala 特定模式

这个分类中的模式可以被归类到之前提到的那些分类中。然而,它们是 Scala 所特有的并利用了一些本书中将会关注的语言特性,因此我们决定将它们放在一个单独的分类中。

这里我们将会关注如下模式:

  • 透镜(lens)模式
  • 蛋糕模式
  • 皮条客(Pimp my library)模式
  • 叠加特质
  • 类型类
  • 惰性求值
  • 偏函数
  • 隐式注入
  • 鸭子类型
  • 记忆(Memoization)模式

透镜模式

Scala 提倡不可变性。对象的不可变可以保证更少的错误。但是有时候可变性在所难免,透镜模式使我们更好的实现可变性。

蛋糕模式

蛋糕模式是 Scala 中实现依赖注入的方式。常被用于实际的应用,有多中库来帮助开发者实现这种模式。Scala 提供了一种使用语言特性实现这种模式的方式,也就是蛋糕模式。

皮条客模式

很多时候开发者需要使用非常泛型的库。但是有时我们需要更接近于我们场景的东西。这种模式支持为这些不可变的库添加扩展方法。或者用于我们自己的库,并且能够提供更好的可读性。

叠加特质

以 Scala 的方式实现装饰器模式。也可以用于组合功能,基于一些 Scala 提倡的特性。

类型类

通过定义一个行为并且能够被指定类型的类的所有成员所支持,来编写泛型代码。比如,所有的数字都必须支持加法和减法操作。

惰性求值

有时需要处理一些耗时或代价昂贵的操作。但是有些时候这些操作最终并不需要。该技术使一些操作只有在需要的时候才会执行。可以用于应用优化。

偏函数

数学和函数式编程非常近似。这些函数被定义为只能处理接收类型的一个子集。

隐式(implicit)注入

基于 Scala 的隐式功能。当对象被需要时进行隐式注入,只要他们在当前作用域。

鸭子类型

Scala 提供的类似于动态语言的特性。

记忆模式

根据输入,通过记住函数结果来提供优化。

如何选择

我们已经见到相当多的设计模式了。在很多场景下他们可以组合使用。不幸的是,对于如何选择概念来设计我们的代码并没有确定的回答。有多种因素会影响最后的决定,你也要问自己下面所列的问题:

  • 这段代码是趋于稳定还是将会被修改
  • 是否需要动态决定算法的选择
  • 代码是否会被他人使用
  • 是否有约定的接口
  • 计划使用哪种库
  • 是否有性能需求或限制

这绝不是一个详尽的问题清单。有大量的因素会指示我们如何来构建系统的决定。然而,真正重要的是要拥有一个清晰的说明(clear specification),如果有些东西看起来没遗漏了,它总是要在第一时间被检查。

在剩余的章节中,我们将会对是否要使用一种设计模式给出一些特定的建议。它们将会帮助你在开始编写代码之前去问正确的问题并作出正确的决定。

总结

2.2.2 - CH02-特质与混入组合

在专研一些实际的设计模式之前,我们需要确保读者对 Scala 中的一些概念都很了解。这些概念中的很多一部分都会在后续用于实现实际的设计模式,同时能够意识到这些概念的能力、限制以及陷阱也是编写正确、高效代码的关键因素。尽管有些概念并不被认为是“官方上”的设计模式,但仍然可以使用它们来编写好的软件。甚至有些场景中,基于 Scala 的丰富特性,一些概念甚至可以使用语言特性来代替一种设计模式。最后,向我们前面提到的,设计模式的存在是因为一种语言的特性缺失,因为特性的不够丰富而不能完成一些需要的任务。

在第一个主题中我们将会了解特质与混入(mixin)组合。他们为开发者提供了一种可能来共享已实现的功能或在应用中为类定义接口。很多由特质与混入组合为开发者带来的可能性对于本书后续要实现的一些设计模式都是很有帮助的。本章将主要关注一下几个主题:

  • 特质
  • 混入组合
  • 多重继承
  • 线性化
  • 测试特质
  • 特质与类

特质

大家或许对 Scala 中的特质持有不同的看法。他们不仅可以被视作类似其他语言中的接口,同时可以被当做一个拥有无参构造器的类。

特质参数

Scala 编程语言充满活力,其发展很快。其中一个将会在 2.12 版本中探讨的趋势是特质参数。更多信息请查看 http://www.scala-lang.org/news/roadmap-next/

在下面的几个小节中,我们将会以不同的视角来了解特质并为你提供一些如何使用它们的想法。

特质作为接口

特质可以看做是其他语言中的接口,比如 Java。但是支持开发者实现其中的一部分或全部方法。每当特质中拥有一些代码,该特质则被称为 mixin(混入、混合)。让我们看一下下面这个例子:

trait Alarm {
  def trigger(): String
}

这里Alarm是一个接口。仅拥有一个trigger方法,不拥有任何实现,如果它被混入一个非抽象的类则必须提供一个该方法的实现。

让我们看一下另一个特质的例子:

trait Notifier {
  val notificationMessage: String
  
  def printNotification(): Unit = {
    println(notificationMessage)
  }
  
  def clear(): Unit
}

这个Notifier特质拥有一个已实现的方法,而notificationMessage的值和clear则需要由混入它的类来提供实现。此外,特质可以要求一个类必须拥有一个指定的变量,这类似于其他语言中的抽象类。

混入带有变量的特质

像上面提到的,特质可以要求类拥有一个指定的变量。一个有趣的用法是我们可以传递一个变量到类的构造器中。这样则可以满足该特质的需求:

class NotifierImpl(val notificationMessage:String) extends Notifier {
  override def clear(): Unit = println(""cleared)
}

这里唯一的必要是在类的定义中该变量必须使用相同的名字并且前面要使用val关键字来修饰。如果上面的代码中我们不适用val关键字作为前缀,编译器则仍然会要求我们实现该特质。这种情况下,我们不得不为类的参数使用一个不同的名字,并在类体中拥有一个override val notificationMessage赋值。

这种行为的原因很简单:如果我们显式的使用val(或 var,根据特质要求),编译器会创建一个字段,并拥有与参数相同作用域的 getter。如果我们仅拥有参数,仅当该参数被用作构造器的作用域之外时(比如用在该类的方法中)才会创建一个字段和内部的 getter。更完备的是,样例类(case class)会自动在参数之前追加val关键字。因此,如果我们使用这个val关键字,我们将会自动获得一个与该参数同名的字段、相同的作用域,同时将会自动覆写特质中对我们的要求。

特质作为类

特质同样可以以类的视角来看待。这种情况下,他们需要实现其所有的方法,同时仅拥有一个不接收任何参数的构造器。考虑下面的代码:

trait Beeper {
  def beep(times: Int): Unit = {
    assert(times >= 0)
    1 to times foreach(i => println(s"Beep number: $i"))
  }
}

现在我们实际上可以直接实例化Beeper并调用其方法。比如下面的例子:

object BeeperRunner extends App{
  val TIMES = 10
  
  val beeper = new Beeper{}		// <= 实例化一个特质
  beeper.beep(TIMES)
}

像预期一样,运行代码后我们将看到如下输出:

Beep number: 1 
Beep number: 2 
Beep number: 3 
....

扩展类

特质同样可以用来扩展类。让我们看一下下面的例子:

abstract class Connector {
  def connect()
  def close()
}

trait ConnectorWithHelper extends Connector {
  def findDriver(): Unit = {
    println("Find driver called.")
  }
}

class PgSqlConnector extends ConnectorWithHelper {
  override def connect(): Unit = {
    println("Connected...")
  }
  
  override def close(): Unit = {
    println("Closed...")
  }
}

和预期的一样,PgSqlConnector会被抽象类约束来实现其抽象方法。你可能会推测,我们可以用一些别的特质来扩展这些类然后再将他们(同时)进行混入。在 Scala 中,我们会对一些情况稍加限制,在后续章节中我们研究组合的时候会来看一下这么做会对我们产生哪些影响。

扩展特质

特质可以被互相扩展。看一下如下例子:

trait Ping {
  def ping():Unit = {
    println("ping")
  }
}

tring Pong {
  def pong():Unit = {
    println("pong")
  }
}

trait PingPong extends Ping with Pong {
  def pingPong():Unit = {
    ping()
    pong()
  }
}

object Runner extends App with PingPong {
  pingPong()
}

上面这个例子是比较简单的,实际上也可以让Runner分别来扩展两个特质。扩展特质可以很好的用来实现特质堆叠这种设计模式,本书的后续部分中我们将会探讨这个话题。

混入组合

Scala 支持开发者在一个类中扩展多个特质。这相对于那些不允许同时扩展多个类的语言增加了多重继承的可能性并且能剩下很多编写代码的精力。在这个子主题中,我们将会展示如果将多个特质混入到一个指定的类,或者在编写代码的时候如何创建一个带有某些指定功能的匿名类。

混入特质

首先,我们先修改一下上个例子的代码。这个改动很小,它将会展示特质是如何被混入的:

object MixinRunner extends App with Ping with Pong {
  ping()
  pong()
}

从上面的代码中可以发现,我们可以将多个特质添加到同一个类中。上面代码中扩展的App特质主要是为了使用其自带的main方法。这会和创建无参类有点类似。

如何混入特质?

将特质混入到类可以使用如下语法:extends with T1 with T2 with T3 with ... Tn

如果一个类已经扩展了别的类,我们仅需要通过with关键字来添加特质。

如果特质方法在特质体内并没有被实现,同时我们混入该特质的类也没有声明为抽象类,则该类必须实现特质中的方法。否则,将会出现编译错误。

组合

创建时的组合给我们提供了一个无需显式定义就能创建匿名类的机会。同样,如果我们想要组合多个不同的特质,创建所有的可能则要花费很多的工作。

组合简单特质

让我们看一个组合简单特质的例子,这里不会扩展其他类或特质:

class watch(brand:String, initialTime: Long){
  def getTime(): Long = System.currentTimeMillis() - initialTime
}

object WatchUser extends App{
  val expensiveWatch = new Watch("expensive brand", 1000L) with Alarm with Notifier {
   override def trigger(): String = "The alarm was triggered."
   override def clear(): Unit = println("Alarm cleared.")
   override val notificationMessage:String = "Alarm is running!"
  }
  val cheapWatch = new Watch("cheap brand", 1000L) with Alarm {
    override def trigger(): String = "The alarm was triggered."
  }
  // show some watch usage.
  println(expensiveWatch.trigger())
  expensiveWatch.printNotification()
  println(s"The time is ${expensiveWatch.getTime()}.")
  expensiveWatch.clear()
  
  println(cheapWatch.trigger())
  println("Cheap watches cannot manually stop the alarm...")
}

在这个例子中我们使用了之前定义的AlarmNotifier特质。我们创建了两个手表实例——一个是贵的,它拥有更多有用的功能;另一个是便宜的,它的功能则会少很多。本质上,他们都是匿名类,在初始化的时候被定义。另外要注意的是,和预期一样,我们需要实现那些我们扩展的特质中的抽象方法。希望这个例子能为你在拥有很多特质及多种可能的组合时带来一些想法。

只是为了完整,下面是上个程序的输出:

The alarm was triggered.
Alarm is running!
The time is 1234567890562.
Alarm cleared.
The alarm was triggered.
Cheap watches cannot manually stop the alarm...

和预期一样,第三行的时间会在每次运行的时候都有所不同。

组合复杂特质

在有些可能的情况下,我们需要组合更加复杂的特质,这些特质可能扩展了一些别的类或特质。如果在继承链的上层没有一个特质或别的特质已经显式扩展了一个指定的类,事情则会变得很简单,他们也不会改变太多。这种情况下,我们可以简单的访问超特质的方法。然而,让我们看一下如果继承链中的特质已经扩展了一个指定的类会发生什么。在这个例子中,我们将会使用之前定义的ConnectorWithHelper特质。该特质扩展了名为Connector的抽象类。加入我们想拥有另一个非常昂贵的手表,比如它可以连接到数据库:

object ReallyExpensiveWatchUser extends App{
  val reallyExpensiveWatch = new Watch("really expensive brand", 1000L) with ConnectorWithHelper {
    override def connect(): Unit = println("Connected with another connector.")
    override def close(): Unit = println("Closed with another connector.")
  }
  
  println("Using the really expensive watch.")
  reallyExpensiveWatch.findDriver()
  reallyExpensiveWatch.connect()
  reallyExpensiveWatch.close()
}

看起来都很好,但是当我们编译的时候,会得到如下错误信息:

Error:(36, 80) illegal inheritance; superclass Watch
	is not a subclass of the superclass Connector of the mixin trait ConnectorWithHelper
	val reallyExpensiveWatch = new Watch("really expensive brand",
	1000L) with ConnectorWithHelper {
^

该错误消息告诉我们,由于ConnectorWithHelper特质已经扩展了Connector类,所有使用该特质进行组合的类必须是Connector的子类。现在让我们假如需要混入另一个同样已经扩展了一个类的特质,但被扩展的这个类与之前不同。根据之前的逻辑,会需要Watch同样需要是该类的子类。但这是不可能出现的,因为我们同时只能扩展一个类,这也就是 Scala 如何来限制多重继承以避免危险错误的发生。

如果我们想要修复这个例子的编译错误,我们不得不去修改原有的Watch类的实现以确保其是Connector的子类。然而这可能并非我们所原本期望的,或许这种情况下需要一些重构。

组合自类型(self-type)

在上一节中,我们看到了如何在Watch类中扩展Connector以便能够编译我们的代码。有些场景中我们或许真的需要强制一个类来混入一个特质,或者同时有其他的特质或多个特质需要混入。让我们加入需要一个闹钟同时带有提醒功能:

trait AlarmNotifier {
  this: Notifier =>
  
  def trigger(): String
}

这里我们展示了什么是自类型。第二行代码将Notifier的所有方法引入到了新特质的当前作用域,它同时要求所有混入了该特质的类必须同时混入Notifier。否则将会出现编译错误。如果使用self来代替this,我们则可以使用手动的方式来在AlarmNotifier中引用Notifier的方法,比如self.printNotification()

下面的代码展示了如何来使用这个新的特质:

object SelfTypeWatchUser extends App {
  AlarmNotifier {
    val watch = new Watch("alarm with notification", 1000l) with AlarmNotifier with Notifier {
      override def trigger():String = "Alarm triggered."
      override def clear(): Unit = println("Alarm cleared.")
      override val notificationMessage:String = "The notification."
    }
  }
  println(watch.trigger())
  watch.printNotification()
  println(s"The time is ${watch.getTime()}.")
  watch.clear()
}

如果在上面的代码中去掉同时扩展Notifier的部分则会出现编译错误。

在这个小节中,我们展示了子类型的简单用法。一个特质可以要求在被混入的同时混入其他一个或多个特质,多个的时候仅需要使用with关键字分割即可。子类型是实现“蛋糕模式”的关键,该模式用于依赖注入。本书后续部分我们会看到更多有趣的用法。

特质冲突

你的脑海中可能已经出现了一个问题——如果我们混入的特质中拥有相同签名的方法会怎样?下面的几个小节我们将会探讨该问题。

相同签名和返回类型

考虑一个例子,我们想要混入的两个特质拥有相同的方法:

trait FormalGreeting {
  def hello():String
}

trait InformalGreeting {
  def hello():String
}

class Greeter extends FormalGreeting with InformalGreeting {
  override def hello():String = "Good morning, ser/madam!"
}

object GreeterUser extends App {
  val greeter = new Greeter()
  println(greetrt.hello())
}

在这个例子中,接待员总是会很有礼貌并且同时混入正式的和非正式的问候。在实现时仅需要实现该方法一次。

相同签名和不同返回类型

如果我们的问候特质拥有更多方法,签名相同但返回类型不同呢?我们将下面的声明添加到FormalGreeting中:

def getTime():String

同时向InformalGreeting中添加:

def getTime():Int

这种情况下我们需要在Greeter中实现同时实现这两个方法。然而,编译器不允许我们定义getTime两次,这表示 Scala 中会避免发生这样的事。

相同签名和返回类型的混入

在继续之前,快速回忆一下混入只是一个带有一些代码实现的特质。这意味着在下面的例子中,我们不需要在使用它们的类中实现这些方法:

trait A {
  def hello(): String = "Hello, I am trait A!"
}

trait B {
  def hello(): String = "Hello, I am trait B!"
}

object Clashing extends App with A with B {
  println(hello())
}

可能和预期一样,我们会得到一个编译错误信息:

Error:(11, 8) object Clashing inherits conflicting members:
	method hello in trait A of type ()String and
	method hello in trait B of type ()String
(Note: this can be resolved by declaring an override in object Clashing.) 
object Clashing extends A with B { 
       ^

该信息很有用,它甚至为我们提供了一个如何修复问题的提示。方法冲突在多重继承中是一个问题,但是和你看到的一样,我们致力于选择一个可用的方法。在Clashing对象中我们或许可以这样修改:

override def hello():String = super[A].hello()

然而,如果处于某些原因我们相同时使用两个方法呢?这种情况下,我们可以创建另外一个名字不同的方法来调用另一个指定特质中的方法。我们同样可以直接通过super符号直接引用这些方法而不是将他们包装在另一个方法中。然而我个人更倾向于包装在另一个方法内,否则代码将会变得很乱。

super 符号

如果在上面的例子中,我们直接使用override def hello(): String = super.hello()而不是super[A]. hello(),真正被选择的又是那个特质中的方法呢?这种情况下将会选择 B 中的方法。这取决于 Scala 中的线性化特性,我们将在后面的章节中详细讨论。

相同签名和不同返回类型的混入

和预期一样,如果方法的传入参数在类型或数量上有所不同则上面的问题就不再存在,因为这成了一个新的签名。但如果特质中有如下两个方法问题则仍然存在:

def value(a: Int): Int = a // in trait A 
def value(a: Int): String = a.toString // in trait B

我用用过的方式在这里不再有效,你可能会对此感到吃惊。如果我们决定仅覆写特质 A 中的方法,将会得到如下编译错误:

Error:(19, 16) overriding method value in trait B of type (a: Int): String; 
  method value has incompatible type 
  override def value(a: Int): Int = super[A].value(a)
  			   ^

如果重写 B 中的方法,错误也会随之改变。而如果两个都覆写,则会得到如下错误:

Error:(20, 16) method value is defined twice 
  conflicting symbols both originated in file '/path/to/traits/src/main/ scala/com/ivan/nikolov/composition/Clashing.scala'
  override def value(a: Int): String = super[B].value(a)

这展示了 Scala 会避免我们在多重继承中进行这样危险的操作。为了完整,如果你遇到类似的问题,仍然存在变通的方式,比如像下面的例子一样,牺牲掉混入的功能:

trait D {
  def value(a:Int):String = a.toString
}

object Example extends App{
  val c = new C{}
  val d = new D{}
  
  println(s"c.value: ${c.value(10)}")
  println(s"d.value: ${d.value(10)}")
}

这段代码中把特质当做合作者使用,但这也丢掉了混入这些特质的类的实例同样也拥有这些特质的类型这一事实(即扩展了特质的类,其实例同时拥有特质的类型),这一性质在某些操作中会很有用。

多重继承

因为可以同时混入多个特质,而且这些特质都可以拥有各自不同的方法实现,因此我们已经在前面的章节中多次提到了多重继承。多重继承不仅是一个强大的技术,同时也很危险,甚至有些语言中决定不进行支持,比如 Java。向我们看到的一样,Scacla 对此进行了支持,不过带有一些限制。本节中我们会接收多重继承的问题及 Scala 是如何处理这些问题的。

菱形问题

多重继承忍受着菱形问题的痛苦。

让我们看一下下面的图示:

NAME

如图,B 和 C 同时扩展了 A,然后 D 同时扩展了 B 和 C。这看起来可能不是很清晰。比如我们有个方法一开始定义在 A,但是 B 和 C 同时覆写了它。当在 D 中调用该方法时会发生什么呢?实际上调用的是哪个方法呢?

因为上面的问题有点模糊或将引起错误。让我们使用 Scala 的特质来重新描述一下该问题:

trait A {
  def hello(): String = "Hello from A"
}

trait B extends A {
  override def hello(): String = "Hello from B"
}

trait C extends A {
  override def hello(): String = "Hello from C"
}

trait D extends B with C {
  
}

object Diamond extends App with D {
  println(hello())
}

运行后会得到如下结果:

Hello from C

如果我们把特质 D 修改为:

trait D extends C with B {

}

则会得到结果为:

Hello from B

你会发现,尽管例子仍然会有点模糊甚至易于出错,我们可以告诉你实际上被调用的是哪个方法。这是通过**线性化(linearization)**实现的,在下一节中会深入介绍。

限制

在我们关注线性化之前,让我们指出 Scala 所支持的多重继承拥有的限制。我们之前已经见过他们很多次,这里会概括描述。

Scala 多重继承限制

Scala 中的多重继承由特质实现并遵循线性化规则。

在多重继承中,如果一个特质已经显式扩展了一个类,则混入该特质的类必须是之前特质混入的类的子类。这意味着当混入一个已扩展了别的类的特质时,他们必须拥有相同的父类。

如果特质中定义声明了相同签名但返回类型不同的方法,则无法混入这些特质。

需要特别小心的是特质中定义了相同签名和返回类型的方法。若果方式仅是声明而被要求实现,这不会带来问题而且只需要提供一个实现即可。

测试特质

测试实际上是软件开发中很重要的一部分。它确保了代码中变更的部分不再产生错误,无论是方法的改变还是其他部分。

我们可以使用多种不同的测试框架,这完全取决于个人喜好。本书中我们将使用 ScalaTest,这也是我在项目中使用的框架;它很容易理解,可读且易于使用。

有些情况下,如果一个特质被混入到了类中,我们则可以直接测试这个类。然而,我们或许仅需要测试一个指定的特质。测试一个没有任何方法实现的特质也没有什么意义,因此这么我会探讨那些拥有代码实现的特质。同时,我们这里展示的单元测实际上是很简单的,他们也仅作为示例。我们会在本书的后续章节讨论更加复杂和有意义的测试。

使用一个类

让我们看一下前面见到的DoubledMultiplierIdentity将会被如何测试。一种方式是将这个特质混入一个测试类来测试它的方法:

class DoubledMultiplierIdentityTest extends Flatspec with ShouldMatchers with DoubledMultiplierIdentity

然而这会编译失败并显示一个错误信息:

Error:(5, 79) illegal inheritance; superclass FlatSpec 
  is not a subclass of the superclass MultiplierIdentity 
  of the mixin trait DoubledMultiplierIdentity 
class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers 
with DoubledMultiplierIdentity { 
^

我们在前面已经谈论过这个问题,事实上特质只能被混入到一个与该特质拥有相同基类的类。这意味着为了测试这个特质,我们需要在我们的测试类中创建一个虚拟的类然后再使用它:

package com.ivan.nikolov.linearization

import org.scalatest.{ ShouldMatchers, FlatSpec}

class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers {
  
  class DoubledMultipliersIdentityClass extends DoubledMultiplierIdentity
  
  val instance = new DoubledMultiplierIdentityClass
  
  "identity" should "return 2 * 1" in {
    instance.identity should equals(2)
  }
}

混入特质

我们可以将特质混入来对他进行测试。有几个地方我们可以这么做:混入到一个测试类或者一个单独的测试用例。

混入到测试类

只有当该特质确定没有扩展任何其他类时,才可以将该特质混入到一个测试类,因此特质、测试类的超类必须是相同的。除了这样,其他任何方式都和我们前面做的一样。

让我们测试一个本节出现过的特质 A,他拥有一个hello方法。同时我们添加了一个pass方法,现在该特质看起来会像下面这样:

trait A {
  def hello(): String = "Hello, I am trait A!"
  def pass(a: Int): String = s"Trait A said: 'You passed $a.'"
}

然后是测试类:

package com.ivan.nikolov.composition

import org.scalatest.{ShouldMatchers, FlatSpec}

class TraitTest extends FlatSpec with ShouldMatchers with A {
  "hello" should "greet properly." in {
     hello() should equal("Hello, I am trait A!")
   }
   
  "pass" should "return the right string with the number." in {
	  pass(10) should equal("Trait A said: 'You passed 10.'") 
   }
  
  it should "be correct also for negative values." in {
    pass(-10) should equal("Trait A said: 'You passed -10.'") 
  }
}

混入到测试用例

我们同样可以将特质分别混入到个别测试案例中。这样可以支持我们单独为这些测试用例设置自定义。下面是对上面那个单元测试的另一种表示:

package com.ivan.nikolov.composition

import org.scalatest.{ ShouldMatchers, FlatSpec }

class TraitCaseScopeTest extends FlatSpec with ShouldMatchers {
  "hello" should "greet properly." in new  A {
    hello() should equal("hello, I am trait A!")
  }
  "pass" should "return the right string with the number." in new A {
    pass(10) should equal("Trait A said: 'You passed 10.'")
  } 
  it should "be correct also for negative values." in new A {
    pass(-10) should equal("Trait A said: 'You passed -10.'")
  }
}

在上面的代码中你可以看到,这些测试用例与前面的例子一样。但是是在单独的用例中混入 A。这支持我们对不同的用例设置自定义,比如一个特质需要一个方法的实现或者一个变量的初始化。这种方式也可以让我们仅专注于要测试的特质,而不需要创建它的一些实际的实例。

运行测试

在测试编写完成后,运行并观察一切是否符合预期是很有用的。仅需要在项目的根目录执行以下命令将会运行所有测试:mvn clean test

如果你需要,可以将 Maven 项目转换为 SBT 项目,然后通过sbt test来触发所有测试。

特质与类

特质或许与类很相似,但同时又有很大的不同。对于开发者来说或许很难在不同的场景中进行选择,不过我们会尝试提供一个通用的指导方针以帮助开发者:

使用类:

  • 当一个行为根本不会被复用或在多个地方出现
  • 如果你计划在其他语言中使用 Scala 代码,比如创建一个将会在 Java 中使用的库

使用特质:

  • 当一个行为将会被复用到多个不相关的类中
  • 当你需要定义一个接口并在 Scala 之外使用,比如 Java 中。因为那些没有任何代码实现的特质被编译后与接口类似

2.2.3 - CH03-统一化

为了能够理解和编写好的 Scala 代码需要开发者熟知语言中不同的概念。到目前,我们已经在几个地方提到了 Scala 是真的很具有表现力。在某种程度上,这是因为有很多编程的概念被统一化了。在本章中,我们将会关注如下概念:

  • 函数与类
  • 代数数据类型和类层级
  • 模块与对象

函数与类

在 Scala 中,所有的值都是一个对象。函数作为第一类值,同时作为他们各自的类的对象。下面的图示展示了 Scala 中被统一的类型系统和实现方式。该图来自 http://www.scala-lang. org/old/sites/default/files/images/classhierarchy.png,它表示了模型的最新视图(有些类比如ScalaObject已经被移除)。

又可以发现,Scala 中并没有 Java 中的原始类型概念,所有的类型最终都是Any的子类型。

NAME

函数作为类

函数作为类实际上意味着如果他们仅仅是值的话,则可以被自由的传递给其他方法或类。这提高了 Scala 的表现力也让他相对与其他语言(比如 Java)更易于实现一些事情,比如回调。

函数字面值

让我们看一个例子:

class FunctionLiterals {
  val sum = (a:Int, b:Int) => a + b
}

object FunctionLiterals extends App{
  val obj = new FunctionLiterals
  println(s"3 + 9 = ${obj.sum(3, 9)}")
}

这里我们可以看到FunctionLiterals类的sum字段是如何被赋值为一个函数的。我们可以将任意函数赋值给一个变量,然后把它当做一个函数调用(实际上是调用了它的apply方法)。函数同样可以作为参数传递给其他方法。让我们向FunctionLiterals类中添加如下代码:

def runOperation(f: (Int, Int) => Int, a: Int, b:Int) = {
  f(a, b)
}

我们可以传递需要的函数到runOperation,像下面这样:

obj.runOperation(obj.sum, 10, 20)
obj.runOperation(Math.max, 10, 20)

没有语法糖的函数

上个例子中我们只是使用了一些语法糖。为了能够理解实际上发生了什么,我们将会展示函数的字面被转换成了什么。他们基本上表示为扩展FunctionN特质,这里 N 是函数参数的数量。函数字面量的实现会通过apply方法被调用(一旦一个类或对象拥有apply方法,则可以通过一对小括号来调用它并传入对应需要的参数)。让我们看一下等同于上个例子的实现:

class SumFunction extends Function2[Int, Int, Int] {
  override def apply(v1:Int, v2:Int): Int = v1 + v2
}

class FunctionObjects {
  val sum = new SumFunction
  
  def runOperation(f:(Int, Int) => Int, a: Int, b: Int): Int = f(a, b)
}

object FunctionObjects extends App{
  val obj = new FunctionObjects
  println(s"3 + 9 = ${obj.sum(3, 9)}")
  println(s"Calling run operation: ${obj.runOperation(obj.sum, 10, 20)}")
  println(s"Using Math.max: ${obj.runOperation(Math.max, 10, 20)}")
}

增加的表现力

像你在例子中看到的一样,统一化的类和函数提升了表现力,你可以轻松实现不同的任务,比如回调、惰性参数求值、集中的异常处理等等,而无需编写额外的代码和逻辑。此外,函数可以作为类意味着我们可以扩展它们以提供能多的功能。

代数数据类型

代数数据类型和类层级是 Scala 中的另一种统一化。在其他的函数式语言中都拥有不同的方式来创建自定义的代数数据类型。在 Scala 中,这是通过类层级来实现的,称为case classobject。让我们看一下 ADT 到底是什么,他们是什么类型,以及如何定义它们。

ADTs

代数数据类型只是一种组合类型,它会组合一些已存在的类型或仅表示一些新类型。它们中仅包含数据而非像常规类一样包含任何数据之上的功能。比如,一周之中的一天,或者一个表示 RGB 颜色的类——他们没有任何功能,仅仅是包含类一些信息。下面的几个小节将会带你洞悉 ADT 是什么以及它们是什么类型。

概括 ADT

整体 ADT 是一种我们可以简单的枚举一个类型所有可能的值并为每个值提供一个单独的构造器。作为一个例子,我们考虑一下一年的所有月份。一年中仅有 12 个月,而且不会改变:

sealed abstract trait Month
case object January extends Month
case object February extends Month 
case object March extends Month 
case object April extends Month 
case object May extends Month 
case object June extends Month 
case object July extends Month 
case object August extends Month 
case object September extends Month 
case object October extends Month 
case object November extends Month 
case object December extends Month

object MonthDemo extends App {
  val month:Month = February
  println(s"The current month is: $month")
}

运行这个程序将会得到如下输出:

The current month is: February

上面代码中的Month是密闭的(使用 sealed 关键字声明),因为我们不想在当前文件之外被扩展。你可以发现,我们将不同的月份定义为对象,因为没有动机让他们成为分开的实例。值就是他们本身,同时也不会改变。

产品 ADT

在产品 ADT 类型中,我们无法枚举所有可能的值。手写他们通常会非常多。我们不能为每个单独的值提供各自的构造器。

比如颜色,有不同的颜色模型,不过最常见的是 RGB。它将主要颜色的不同值进行组合(红绿蓝)以表示其他颜色。假如每种颜色的值区间为 0 - 255,这意味着要表示所有可能的颜色,我们需要 256 的三次方个构造器。因此我们可以使用一个产品 ADT:

sealed case class REB(red: Int, green:Int, blue:Int)

object RGBDemo extends App {
  val magenta = RGB(255, 0, 255)
  println(s"Magenta in RGB is: $magenta")
}

可以发现,对于产品 ADT 来说,所有不同的值拥有同一个构造器。

混合 ADT

混合 ADT 表示一个概况和产品的组合。这意味我们可以拥有特定的值构造器,不过这些值构造器同时接收参数以包装其他类型。

让我们看一个例子。加入我们正在编写一个绘图程序:

sealed abstract trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(height:Double, width:Double) extends Shape

我们拥有不同的形状。这个例子中展示了一个概况 ADT,因为我们拥有CircleRectangle两个特定的值构造器。同时我们也拥有一个产品 ADT,因为这两个特定的值构造器同时接收额外的参数以表示更多的类型。

我们把这个类展开一点。当绘制形状时,我们需要知道其位置。因此我们可以添加一个 Point 类来持有 x 和 y 坐标:

case class Point(x:Double, y:Double)

sealed abstract trait Shape
case class Circle(centre:Point, radius:Double) extends Shape
case class Rectangle(topLeft:Point, height:Double, width:Double) extends Shape

希望这有助于理解 Scala 中的 ADT 是什么以及如何使用它们。

统一化

在所有上面的例子之后,可以明显的发现类层级和 ADT 是被统一化的而且看起来是同一件事。这给语言添加了一个更高级别的灵活性,使相其对于其他函数式语言中的建模则更加容易。

模式匹配

模式匹配常用于 ADT。当基于 ADT 的值来做什么事的时候它会使代码更清晰易读,相对于if-else语句也更易扩展。你可能会认为这些语句在有些场景会难以处理,特别是某个数据类型拥有多种可能的值时。

对值进行模式匹配

在之前月份的例子中,我们仅拥有月份的名字。我们或许需要他们的数字,否则电脑将不知道这是什么。下面展示了做法:

object Month {
  def toInt(month: Month): Int = month match { 
    case January => 1 
    case February => 2 
    case March => 3 
    case April => 4 
    case May => 5 
    case June => 6 
    case July => 7 
    case August => 8 
    case September => 9 
    case October => 10 
    case November => 11 
    case December => 12
  }
}

你可以看到我们基于他们来匹配不同的值,并最终返回这行正确的值。现在可以这样使用该方法:

println(s"The current month is: $month and it's number ${Month.toInt(month)}")

和预期一样,我们的应用会生成如下输出:

The current month is: February and it's number 2

实际上因为我们指定了基特质为密闭约束,因此在我们的代码之外没人能够扩展它们,同时我们拥有一个没有遗漏的模式匹配。不完整的模式匹配将会充满问题。作为实验,如果我们将二月份注释掉并编译我们的代码,将会得到如下警告:

Warning:(19, 5) match may not be exhaustive.
It would fail on the following input: February
	month match {
	^

运行这个例子将会证明这个警告的正确性,当传入参数使用February时我们的代码将会失败。为了完整,我们可以添加一个默认的情况:

case _ => 0

对产品类型进行模式匹配

当模式匹配用于产品或混合 ADT 时将会展示其强大力量。在这种情况下,我们可以匹配这些数据类型的实际值。让我们展示如何实现一个功能来计算面积的值:

object Shape{
  def area(shape: Shape):Double = shape match {
    case Circle(Point(x, y), radius) => Math.PI * Match.pow(radius, 2)
    case Rectangle(_, h, w) => h * w
  }
}

在匹配时可以忽略我们不关心的值。对于面积,我们实际上不需要位置信息。在上面的代码中,我们仅仅展示了匹配的两种方式。下划线_操作符可以用于match语句的任何位置,它仅仅会忽略所处位置的值。在这之后,直接使用我们的例子:

object ShapeDemo extends App {
  val circle = Circle(Point(1, 2), 2.5)
  val rect = Retangle(Point(6, 7), 5, 6)
  
  println(s"The circle area is: ${Shape.area(circle)}")
  println(s"The rectangle area is: ${Shape.area(rect)}")
}

可以得到类似下面的输出:

The circle area is: 19.634954084936208 
The rectangle area is: 30.0

在模式匹配时,我们甚至可以使用常量来代替变量来作为 ADT 的构造器参数。这使语言更加强大并支持我们实现更加复杂的逻辑,并且看起来也会很不错。你可以根据上面的例子进行试验以便更深入理解模式匹配的工作原理。

模式匹配常用语 ADT,它有助于实现清晰、可扩展及无遗漏的代码。

模块与对象

模块是组织程序的一种方式。它们是可互换的和可插拔的代码块,有明确定义的接口和隐藏的实现。在 Java 中,模块以包的形式组织。在 Scala 中,模块是对象,就像其他的一切一样。这意味它们可以被参数化、被扩展、像参数一样传递,等等。

Scala 的模块可以提供必要环境以便使用。

使用模块

我们已经总结了 Scala 中模块也是对象而且同样被统一化了。这意味着我们可以在我们的应用中传递一个完整的模块。这可以很有用,然而,下面展示一个模块看起来是什么样子:

trait Tick {
  trait Ticker {
    def count(): Int
    def tick(): Unit
  }
  def ticker:Ticker
}

这里,Tick仅是我们一个模块的一个接口。下面是它的实现:

trait TickUser extends Tick {
  class TickUserImpl extends Ticker{
    var curr = 0
    override def cont(): Int = curr
    override def tick():Unit = curr = curr + 1
  }
  object ticker extends TickUserImpl
}

这个TickUser实际上是一个模块。它实现了Tick并在其中隐藏了代码。我们创建了一个单例对象来提供实现。对象的名字和Tick中的方法名一样。这样混入该特质的时候则能满足其实现的需求。

类似的,我们可以像下面这样定义一个接口和实现:

trait Alarm {
  trait Alarmer {
    def trigger(): Unit
  }
  def alarm:Alarmer
}

然后是实现:

trait AlarmUser extends Alarm with Tick{
  class AlarmUserImpl extends Alarmer {
    override def trigger(): Unit = {
      if(trigger.count() % 10 == 0){
        println(s"Alarm triggered at ${ticker.count()}!")
      }
    }
  }
  object alarm extends AlarmUserImpl
}

有意思的是我们在AlarmUser同时扩展了两个模块。这展示了模块可以怎样互相基于对方。最终,我们可以像下面这样使用这些模块:

object ModuleDemo extends App with Alarmer with TickUser{
  println("Running the ticker. Should trigger the alarm every 10 times.")
  (1 to 100).foreach {
    case i => 
      ticker.tick()
      alarm.trigger()
  }
}

为了让ModuleDemo能够使用AlarmUser模块,编译器会要求混入TickUser或任何混入了Tick的任何模块。这提供了一种可能性来插拔一个不同的功能。

下面是程序的输出:

Running the ticker. Should trigger the alarm every 10 times. Alarm triggered at 10!
Alarm triggered at 20!
Alarm triggered at 30!
Alarm triggered at 40!
Alarm triggered at 50!
Alarm triggered at 60!
Alarm triggered at 70!
Alarm triggered at 80!
Alarm triggered at 90!
Alarm triggered at 100!

Scala 中的模块可以像其他对象一样传递。他们是可扩展的、可互换的,并且实现是被隐藏的。

2.2.4 - CH04-抽象与自类型

为了能够拥有易于扩展和维护的应用,在软件工程中设计和编写高质量的代码是非常重要的。这些活动要求开发者能够熟悉并正确理解领域,同时应用的需求也能被清晰的定义。如果缺少了任何一项,那么想要编写好的程序则会称为一个挑战。

很多时候,工程师使用一些抽象来建模“世界”。这有助于代码的可扩展性和可维护性,并能避免重复,这在在很多情况下可能会成为错误的原因。好的代码通常有多个小的组件构成,它们会互相依赖或集成。有多种不同的方式来实现抽象和集成。我们将会在本章中关注以下概念:

  • 抽象类型
  • 多态
  • 自类型

这里所涉及的几个主题将非常有助于我们研究具体的设计模式。理解它们也能有助于理解那些基于它们的设计模式。此外,使用本章所涉及的这些概念本身就能有助于编写良好的代码。

泛型与抽象类型

参数化一个类的常用方式之一是使用值。这十分简单,可以通过向类的构造器参数传入不同的值来实现。在下面的例子中,我们可以向Person类的name参数传入不同的值来创建不同的实例:

case class Person(name:String)

以这种方式我们能够创建不同的实例并将他们加以区分。但这样既不有趣也不是“火箭科学”。更进一步,我们会关注一些更加有趣的参数化来帮助我们编写更好的代码。

泛型

泛型是另一种参数化类的方式。当我们编写一个要操作多种不同类型的功能时,泛型会很有用,同时我们能够简单的推迟到最后再选择具体类型。一个例子是开发者所熟知的集合类。比如List,可以保存任何类型的数据,我们可以拥有整形、双精度浮点型、字符串、自定义类等等类型的列表。即便如此,列表的实现仍然总会是一样的。

我们同样可以参数化方法。比如,如果我们想要实现加法,对于不同的数字类型不会有什么不同之处。因此,我们可以使用泛型并仅实现方法一次,而不需要重载来适应世界上的每个类型。

让我们看一些例子:

trait Adder{
  def sum[T](a: T, b: T)(implicit numeric:Numeric[T]): T = 
    numeric.plus(a, b)
}

上面这段代码可能有点难懂,它定义了一个可以用于numeric类型的sum方法。这实质上是一个专用(ad-hoc)泛型的表示,我们将会在本章的后续部分讨论。

下面的代码展示了如何泛型一个能够包含任何数据的类:

class Container[T](data: T) {
  def compare(other:T) = data.equals(other)
}

下面的片段展示了例子的用法:

object GenericsExample extends App with Adder {
  println(s"1 + 3 = ${sum(1, 3)}")
  println(s"1.2 + 6.7 = ${sum(1.2, 6.7)}")
  
  // compilation fails
  // System.out.println(s"abc + cde = ${sum("abc", "cde")}") 
  
  val intContainer = new Container(10)
  println(s"Comparing with int: ${intContainer.compare(11)}")
  
  val stringContainer = new Container("some text")
  println(s"Comparing with string: ${stringContainer.compare("some text")}")
}

运行程序将会得到如下输出:

1 + 3 = 4
1.2 + 6.7 = 7.9
Comparing with int: false
Comparing with string: true

抽象类型

另一种参数化类的方式是使用抽象类型。泛型在其他语言中都有对应的实现,比如 Java。但是 Java 中却不存在抽象类型。让我们看一下上面Container的例子如何转换为以抽象类型的方式实现:

trait ContainerAT{
  type T
  val data:T
  
  def conpare(other:T) = data.equals(other)
}

然后在类中使用这个特质:

class StringContainer(val data:String) extends ContainerAT {
  override type T = String
}

然后就可以使用与前面相同的方式来使用这个类:

object AbstractTypesExample extends App{
  val stringContainer = new StringContainer("some text")
  println(s"Comparing with string: ${stringContainer.compare("some text")}")
}

同样可以得到与预期一样的输出:

comparing with string: true

当然我们也可以以类似的方式应用于泛型的例子,只需要创建一个特质的实例然后指定类型参数。这意味着泛型和抽象类型为我们提供了两种方式来实现相同的一件事。

泛型与抽象类型

那为什么 Scala 中同时拥有泛型和抽象类型呢?它们有什么不同吗?或者如何选择使用哪一个呢?我们会在这里给你答案。

泛型和抽象类型是可以互换的。虽然可能需要额外的工作,但是我们能够使用泛型来提供抽象类型所带来的一切。如何选择取决于不同的因素,有的是个人偏好,比如有人是为了可读性,而有人则是为了类的不同用法。

让我们看一个例子来尝试理解泛型与抽象类型可以在合适以及如何使用。在这个例子中我们会使用打印机。大家都知道它们有多种类型——纸质打印机、3D 打印机等等。每种都是用不同的材料来打印,比如墨粉、墨水或塑料,同时它们也会于打印到不同的媒介上——纸或甚至是空气中。我们可以使用抽象类型来描述这些:

abstract class PrintData
abstract class PrintMaterial
abstract class PrintMedia
trait Printer{
  type Data <: PrintData
  type Material <: PrintMaterial
  type Media <: PrintMedia
  
  def print(data:Data, material:Material, media: Media) = 
    s"Printing $data with $material material on $media media."
}

为了能够调用这个print方法,我们需要拥有不同的媒介、数据类型及原料:

case class Paper() extends PrintMedia
case class Air() extends PrintMedia
case class Text() extends PrintData
case class Model() extends PrintData
case class Toner() extends PrintMaterial
case class Plastic() extends PrintMaterial

现在让我们创建两个具体的打印机实现,一个激光打印机,一个 3D 打印机:

class LaserPrinter extends Printer {
  type Media = Paper
  type Data = Text
  type Material = Toner
}

class ThreeDPrinter extends Printer {
  type Media = Air
  type Data = Model
  type Material = Plastic
}

在上面的代码中,我们实际上已经给出了数据类型、媒介,以及打印机可以使用的材料的说明。我们不能要求 3D 打印机使用墨粉来打印,或者激光打印机直接打印在空气中。下面是如何使用这两个打印机:

object PrinterExample extends App{
  val laser = new LaserPrinter
  val threeD = new ThreeDPrinter
  
  println(laser.print(Text(), Toner(), Paper()))
  println(threeD.print(Model(), Plastic(), Air()))
}

这段代码拥有很好的可读性,它支持我们轻松的指定具体类。使事物更易于建模。有意思的是将其转换为泛型的方式实现则会是这样:

trait GenericPrinter[Data <:PrintData, Material <: PrintMaterial, Media <: PrintMedia] {
  def print(data: Data, material: Material, media: Media) =
    s"Printing $data with $material material on $media media."
}

这个特质很容易被描述,可读性和逻辑性在这里也没有得到损害。然而,我们必须以如下方式来表示具体类:

class GenericLaserPrinter[Data <: Text, Material <: Toner, Media <: Paper] extends GenericPrinter[Data, Material, Media]

class GenericThreeDPrinter[Data <: Model, Material <: Plastic, Media <: Air] extends GenericPrinter[Data, Material, Media]

这会让具体类的定义变得相当长,开发者也更可能犯错。下面的片段展示了如何使用这些类来创建实例:

val genericLaser = new GenericLaserPrinter[Text, Toner, Paper]
val genericThreeD = new GenericThreeDPrinter[Model, Plastic, Air]
println(genericLaser.print(Text(), Toner(), Paper()))
println(genericThreeD.print(Model(), Plastic(), Air()))

你会发现每次在创建这些实例的时候都需要指定类型。假如我们拥有更多的泛型类型,或者一些类型本身又是基于泛型,比如集合。这很快会变得冗长,而且让人难以理解这些代码的实际用途。

另一方面,使用泛型能够允许我们复用GenericPrinter而不必为不同的打印机表示进行显式的子类化。然而这也存在逻辑错误的风险:

class GenericPrinterImpl[Data <: PrintData, Material <: PrintMaterial, Media <: PrintMedia] extends GenericPrinter[Data, Material, Media]

如果像相面这样使用则会有犯错的危险:

val wrongPrinter = new GenericPrinterImpl[Model, Toner, Air]
println(wrongPrinter.print(Model(), Toner(), Air()))

应用建议

上面的例子展示了使用泛型和抽象类型的简单比较。两种都是有用的概念;然而,清晰的知道他们具体的用法对于在需要的场景选择正确的一个是很重要的。下面的一些技巧能够帮助你做出正确的选择:

  • 泛型:
    • 如果你仅需要类型实例化。一个好的示范是标准的集合类。
    • 如果你正在创建一族类型。
  • 抽象类:
    • 如果你想允许别人能够通过特质混入类型。
    • 如果你需要在一些允许互换的场景拥有更好的可读性。
    • 如果你想在客户端代码中隐藏类型定义。

多态的三种形式

多态是任何使用面向对象编程语言的开发者都知道的东西。

多态帮助我们编写通用的代码以进行复用并应用到多样性的类型中。

知道有多种不同类型的多态是很重要的,这节中我们将会讨论它们。

子类型多态

这是一种每个开发者都知道的多态,它与在具体类中覆写方法相关。考虑下面简单的层级:

abstract class Item {
  def pack:String
}

class Fruit extends Item {
  override def pack:String = "I'm a fruit and I'm packed in a bag."
}

class Drink extends Item {
  override def pack: String = "I'm a drink and I'm packed in a bottle."
}

现在我们拥有一个装满物品的购物篮,对每一个都进行pack调用:

object SubtypePolymorphismExample extends App {
  val shoppingBasket:List[Item] = List(
    new Fruit, new Drink
  )
  shoppingBasket.foreach(i => println(i.pack))
}

你会发现,这里我们可以使用一个抽象类型并且无需思考它们具体的类型直接调用pack方法即可。多态会注意打印正确的值。我们的输出会像下面这样:

I'm a fruit and I'm packed in a bag. 
I'm a drink and I'm packed in a bottle.

子类化多提通过extends关键字使用继承来表示。

参数式多态

函数式编程中的参数化多态即为我们上节中展示的泛型。泛型既是参数化多态,我们已经见过,它支持我们定义基于任何类型或给定类型的子集类型的方法和数据结构。而具体的类型则可以在后续的阶段指定。

参数式多态使用类型参数表示。

专用(ad-hoc)多态

专用多态类似于参数式多态;然而在这种情况下,参数的类型是很重要的,因为具体实现会依赖于这些参数。它会在编译时被分析,不像子类型多态是在运行时。这多少类似于函数重载。

我们在本章的前面部分看到过它的一个例子,我们创建了一个Adder特质可以对不同的类型求和。让我们一步一步定义一个更加精密的例子,期望这样有助于连接它是如何工作的。我们的目标是让sum方法可以对任何类别的类型(之前是数字类型)求和:

trait Adder[T]{
  def sum(a:T, b:T): T
}

下一步,我们创建一个使用sum方法的对象并向外界暴露:

object Adder {
  def sum[T: Adder](a: T, b: T): T = 
    implicitly[Adder[T]].sum(a, b)
}

我们看到的上面这段代码是 Scala 中的一些语法糖,implicitly表示拥有一个隐式转换可以将T转换为Adder[T]。现在我们可以编写如下程序:

object AdhocPolymorphismExample extends App{
  import Adder._
  println(s"The sum of 1 + 2 is ${sum(1, 2)}")
  println(s"The sum of abc + def is ${sum("abc", "def")}")
}

如果我们尝试编译运行这个程序,将会得到如下错误:

Error:(15, 51) could not find implicit value for evidence parameter 
of type com.ivan.nikolov.polymorphism.Adder[Int]
	System.out.println(s"The sum of 1 + 2 is ${sum(1, 2)}")
												  ^
Error:(16, 55) could not find implicit value for evidence parameter 
of type com.ivan.nikolov.polymorphism.Adder[String]
	System.out.println(s"The sum of abc + def is ${sum("abc", "def")}")
													  ^

这表示我们的代码不知道如何将整数或字符串隐式转换为Adder[Int]Adder[String]。我们需要做的是定义这些转换以告诉程序sum方法该如何做。我们的Adder对象看起来会是这样:

object Adder {
  def sum[T: Adder](a: T, b: T): T = implicitly[Adder[T]].sum(a, b)
  
  implicit val int2Adder:Adder[Int] = new Adder[Int] {
    override def sum(a:Int, b:Int):Int = a + b
  }
  
  implicit val string2Adder:Adder[String] = new Adder[String] {
    override def sum(a:String, b:String):Int = s"$a concatenated with $b"
  }
}

现在如果允许上面的程序则会得到如下输出:

The sum of 1 + 2 is 3
The sum of abc + def is abc concatenated with def

同样,如果你记得本章开头的例子,我们是不能对字符串求和的。现在你会发现,我们可以提供不同的实现,因为我们定义了一个转换为Adder方式,因此使用任何类型都是没有问题的。

专用泛型支持我们在不修改基类的基础上扩展代码。这在使用外部库时将会很有帮助,比如因为某些原因我们无法修改原始代码。它很强大而且在编译时求解,这可以确保我们的程序会和预期的一样运行。另外,它可以支持我们为无法访问的类型(比如这里的 Int、String)提供功能。

为多个类型提供功能

如果我们回头看本章的开头,我们为数字类型定义了一个Adder,会发现后面最终的实现会要求我们为不同的数字类型单独定义不同的操作,比如 Long、Double等等。有没有方式来实现本章开头的哪种效果呢?当然有,就像下面这样:

implicit def numeric2Adder[T: Numeric]:Adder[T] = new Adder[T]{
  override def sum(a: T, b: T) = implicitly[Numeric[T]].plus(a, b)
}

我们仅仅定义了另一个隐式转换(将Numeric[T]转换为Adder[T]),它会为我们处理好一切。现在可以像下面这样使用:

println(s"The sum of 1.2 + 6.5 is ${sum(1.2, 6.5)}")

专用多态通过隐式混入行为来表示。这是类型类设计模式的主要构建方式,后续章节中将会详细解释。

例子中,首先定义一个泛型接口,然后通过传入具体类型参数来创建不同类型的具体子类实例,从而调用支持不同类型的实例方法。只是这个过程是通过隐式转换完成的。

自类型

好代码的其中一个特性是关注点分离。开发者需要致力于使类与其方法的职责仅有一个。这有助于测试和维护,而且更有助于简化代码的理解。记得,简单的总是最好的

然而,在编写实际软件时总是无法避免,为了实现某些功能我们需要在一个类的实例中持有别的类的实例。换句话说,一旦我们的对构件块进行了清晰的分离,为了实现功能它们之间则会存在依赖。我们这里所讨论的总结起来实际上就是依赖注入。自类型提供了一种方法来以更加优雅的方式来处理这些依赖。本节中,我们将讨论它们的用法及优点。

使用自类型

自类型支持我们在应用中更简便的拆分代码,然后再在其他地方指定那些需要的部分。例子会让一切变得清晰,因此让我们看一个实例。假如我们想要往数据库持久化信息:

trait Persister[T] {
  def persist(data:T)
}

persist方法会对数据做一些转换并存储到我们的数据库。当然,我们的代码写的很好,因此数据库实现是互相分离的。我们拥有以下几种数据库:

trait Database[T] {
  def save(data: T)
}

trait MemoryDatabase[T] extends Database[T] {
  val db:mutable.MutableList[T] = mutable.MutableList.empty
  override def save(data:T):Unit = {
    println("Saving to in memory database.")
    db.+=:(data)
  }
}

trait FileDatabase[T] extends Database[T] = {
  override def save(data:T):Unit = {
    println("Saving to file.")
  }
}

我们拥有一个特质及一些具体的数据库实现。那么如何把数据库传递给Persister呢?它应该能够调用数据库中的save方法。可能会有以下几种方式:

  • Persister中扩展Database。这样虽然可行,但是会让Persister也变成了Database的实例,我们并不希望这样。后面会解释原因。
  • Persister中拥有一个Database变量,然后使用它。
  • 使用自类型。

为了能够观察自类型是如何工作的,因此使用自类型的方式。我们的Persister接口将会变成下面这样:

trait Persister[T] { this: Database[T] =>
  def persist(data:T):Unit = {
    println("Calling persist.") 
    save(data)
  }
}

现在我们访问了数据库中的方法并在Persister之内调用了save方法。

为自类型命名

在上面的代码中,我们使用this: Database[T] =>语句将自类型包括进来。这样支持我们像使用自身的方法一样直接使用被引入类型的方法。另一种替代的方式是使用self: Database[T] =>。有很多例子中使用了后面的方式,这样可以避免当我们需要在嵌套的特质或类定义中使用this而引起的混乱。然而这种方式需要在调用被注入的方法时使用self来引用。

自类型会要求任何混入Persister类同时混入Database,否则编译将会失败。让我们创建一些持久化到内存和数据库的类:

class FilePersister[T] extends Persister[T] with FileDatabase[T]
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T]

最终,我们可以在应用中使用它们:

object PersisterExample extends App {
  val fileStringPersister = new FilePersister[String]
  val memoryIntPersister = new MemoryPersister[Int]
  
  fileStringPersister.persist("something")
  fileStringPersister.persist("something else")
  
  memoryIntPersister.persist(100)
  memoryIntPersister.persist(123)
}

自类型与继承所做的事是不同的,它需要一些代码的存在,因此也能支持我们对功能进行很好的拆分。这可以很大的改变的对程序的维护、重构和理解。

使用多个组件

在真实的应用中,可能需要使用自类型来对多个组件做出要求。让我们在例子中展示一个Histoty特质,它能够追踪改变并回滚到某个点。不过这里仅做一些打印:

trait History {
  def add():Unit = {
    println("Action added to history.")
  }
}

我们需要在Persister中使用它,看起来像是这样:

trait Persister[T] { this: Database[T] with History =>
  def persist(data:T):Unit = {
    println("Calling persist.")
    save(data)
    add()
  }
}

我们可以通过with关键字同时添加多个需求。然而,如果我们仅让代码做出这些改变,它并不会编译成功。原因是现在我们必须同时混入HistoryPersister中:

class FilePersister[T] extends Persister[T] with FileDatabase[T] with History
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History

然后再次运行代码,将会得到如下输出:

Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to in memory database. Action added to history. Calling persist.
Saving to in memory database. Action added to history.

组件冲突

在上面的例子中,我们拥有一个对History特质的需要,它拥有一个add方法。如果不同组件中的方法拥有相同的签名会怎样呢?让我们试一下:

trait Mystery {
  def add(): Unit = {
    println("Mystery added!")
  }
}

然后使用到Persister中:

trait Persister[T] { this:Database[T] with History with Mystery =>
  def persist(data:T):Unit = {
    println("Calling persist.")
    save(data)
    add()
  }
}

class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery 
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery

如果我们允许这个应用,将会得到如下错误信息:

Error:(47, 7) class FilePersister inherits conflicting members:
	method add in trait History of type ()Unit and
	method add in trait Mystery of type ()Unit
(Note: this can be resolved by declaring an override in class FilePersister.)
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery
^
Error:(48, 7) class MemoryPersister inherits conflicting members:
	method add in trait History of type ()Unit and
	method add in trait Mystery of type ()Unit
(Note: this can be resolved by declaring an override in class MemoryPersister.)class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery
							   ^

幸运的是这个错误消息已经包含了一些如何修复问题的信息。这跟我们一开始使用特质时遇到的完全是相同的问题,我们可以使用如下的方式修复:

class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery{
  override def add():Unit = super[History].add()
}
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery {
  override def add(): Unit = super[Mystery].add()
}

然后再次运行例子,将会得到预期的输出:

Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to in memory database. Mystery added!
Calling persist.
Saving to in memory database. Mystery added!

自类型与蛋糕模式

上面我们看到的例子都是单纯的依赖注入的例子。我们通过自类型要要求一个组件必须引入一些指定的组件。

自类型常用于依赖注入。他们是蛋糕设计模式的主要部分,本书后续部分我们将详细介绍。

蛋糕模式的实现完全依赖于自类型。它鼓励工程师编写小而简单的组件,然后声明并使用它们的依赖。当应用中所有的组件都编写完成后,可以在一个通用的组件中实例化它们以应用于实际的应用。蛋糕模式的一个好的优势是实际上会在编译时来检查所有的依赖是否都满足了。

在本书的后续部分,我们将使用一整个小节来讨论蛋糕模式,那里我们将讨论更多关于该模式是如何被连接在一起的细节,它的优势及缺陷,等等。

自类型与继承对比

在上一节中,我们讲到不希望使用继承的方式来访问Database的方法。这又是为何呢?如果我们让Persister扩展Database,这意味着Persister本省也变成了一个Database(is-a 关系)。然而这是不正确的。这里它只是使用一个数据库来实现其功能,而不能称为一个数据库。

继承将子类暴露给父级的实现细节。然而并非总是期望得到这样的结果。根据Design Patterns: Elements of Reusable Object-Oriented Software一书的作者所提倡的,开发者总是应该优先使用组合,而不是继承。

继承泄露了功能

如果我们使用了继承,同样会将我们不希望的功能泄露给子类。让我们看一下下面的代码:

trait DB {
  def connect():Unit = println("Connected.")
  def dropDatabase():Unit = println("Dropping!")
  def close():Unit = println("Closed.")
}
trait UserDB extends DB{
  def createUser(username:String):Unit = {
    connect()
    try {
      println(s"Creating a user: $username")
    } finally{
      close()
    }
  }
  
  def getUser(username:String):Unit = {
    connect()
    try{
      println(s"Getting a user: $username")
    } finally {
      close()
    }
  }
}

trait UserService extends UserDB{
  def bad():Unit = dropDatabase()
}

这会是一个真实的情况。因为这就是继承的工作方式,我们可以在UserService中访问dropDatabase。这是一些我们不希望发生的事情,而且可以通过自类型来修复。特质DB不需要变动,仅需要修改以下内容:

trair UserDB{ this:DB =>
  def createUser(username:String):Unit = {
    connect()
    try{
      println(s"Creating a user: $username")
    } finally close()
  }
  def getUser(username:String):Unit = {
    connect()
    try{
      println(s"Getting a user: $username")
    } finally close()
  }
}

trait UserService{ this: UserDB =>
  //...
}

这样,在UserService中就无法再访问到dropDatabase了。我们只能调用我们需要的方法,这也就是我们要真正实现的。

2.2.5 - CH05-AOP 与组件

AOP 与组件

有些时候在编程中,我们会发现一些代码片段重复出现在不同的方法中。有些情况下,我们会重构我们的代码将它们移到单独的模块中。有时这又是行不通的。有一些经典的日志和验证的例子。面向切面编程在这种情况下会很有帮助,我们将在本章的末尾来理解它。

组件是可复用的代码片段,它会提供多种服务并具有一定的要求。它们对避免代码重复极其有用,当然也能促进代码的复用。这里我们将会看到如何构建组件,以及 Scala 是如何让组件的编写和使用相比其他语言更加简单。

在熟悉面向切面编程和组件的过程中,我们将会贯穿以下主题:

  • 面向切面编程
  • Scala 中的组件

面向切面编程

面向切面编程,即 AOP(Aspect-oriented programming),解决了一个通用的功能,贯穿于整个应用,但是又无法使用传统的面向对象技术抽象成一个单独的模块。这种重复的功能通常被引用为“横切关注点(cross-cutting concerns)”。一个通用的例子是日志——通常日志记录器会在一个类中创建,然后在类的方法中调用这个记录器的方法。这有助于调试和追踪应用中的事件,但由于应用实际的功能没什么相关性。

AOP 推荐将横切关注点抽象并封装在它们自己的模块中。在下面的几个章节中我们将会深入了解 AOP 如何来改善代码并能够使横切关注点更易扩展。

实例

效率是每个程序中很重要的部分。很多情况下,我们可以对方法计时以查找应用中的瓶颈。让我们看一个示例程序。

我们将会看一下解析。在很多真实的应用中,我们需要以特定的格式读取数据并将其解析为我们代码中的对象。比如,我们拥有一个记录人员的小数据库并以 JSON 格式表示:

[
  {
	"firstName": "Ivan", 
    "lastName": "Nikolov", 
    "age": 26 
  }, 
  { 
    "firstName": "John", 
    "lastName": "Smith", 
    "age": 55 }, 
  { 
    "firstName": "Maria", 
    "lastName": "Cooper", 
    "age": 19 
  }
]

为了在 Scala 中表示这段 Json,我们需要定义模型。这会很简单而且只有一个类:Person。下面是代码:

case class Person(firstName:String, lastName:String, age:Int)

由于我们要读取 Json 输入,因此要对其进行解析。有很多解析器,每种或许会有各自不同的特性。在当前这个例子中我们将会使用 Json4s。这需要在pom.xml中配置额外的依赖:

<dependency> 
  <groupId>org.json4s</groupId> 
  <artifactId>json4s-jackson_2.11</artifactId> 
  <version>3.2.11</version> 
</dependency>

上面这个依赖可以轻易转换为 SBT,如果读者更愿意使用 SBT 作为构建系统的话。

我们要编写一个拥有两个方法的类,用于解析前面所指定的格式的输入文件并返回一个Person对象列表。这两个方法实际上在做同一件事,但是其中一个会比另一个效率更高:

trait DataReader{
  def readData():List[Person]
  def readDataInefficiently():List[Person]
}

class DataReaderImpl extends DataReader{
  implicit val formats = DefaultFormats
  private def readUnitimed():List[Person] = 
    parse(StreamInput(getClass.getResourceAsStream("/users/json"))).
      extract[List[Person]]

  override def readData():List[Person] = readUntimed()
  
  override def readDataInefficiently():List[Person] = {
    (1 to 10000).foreach{
      case num => readUntimed()
    }
    readUntimed()
  }
}

特质DataReader扮演一个借口,对实现的使用也十分直接:

object DataReaderExample extends App{
  val dataReader = new DataReadImpl
  System.out.println(s"I just read the following data efficiently: ${dataReader.readData()}") 
  System.out.println(s"I just read the following data inefficiently: ${dataReader.readDataInefficiently()}")
}

运行这段代码将会得到和预期一样的结果。

上面的这个例子是很清晰的。然后,如果你想优化你的代码并查看运行缓慢的原因呢?上面的代码并没有提供这种能力,因此我们要做一些额外的工作来对应用计时并查看它是如何执行的。下一个小节我们将会同时展示不适用 AOP 和使用 AOP 的实现。

不使用 AOP

有一种基本的方法来进行计时。我们可以把println语句包裹起来,或者让计时称为DataReaderImpl类方法的一部分。通常,将计时作为方法的一部分会是一个更好的选择,因为这个方法可能会在不同的地方被调用,同时它们的性能也取决于传入的参数和一些其他因素。基于我们所说,这也就是DataReaderImpl类将会如何被重构以支持计时的方式:

class DataReaderImpl extends DataReader {
  implicit val formats = DefaultFormats
  private def readUnitimed():List[Person] = parse(StreamInput(getClass.getResourceAsStream("users.json"))).extract[List[Person]]
  override def readData(): List[Person] = {
    val startMillis = System.currentTimeMillis()
    val result = readUnitimed()
    val time = System.currentTimeMillis() - startMillis
    System.err.println(s"readData took $time milliseconds")
    result
  }
  
  override def readDataInefficiently():List[Person] = {
    val startMillis = System.currentTimeMillis()
    (1 to 1000) foreach {
      case num => readUntimed()
    }
    val result = readUntimed()
    val time = System.currentTimeMillis() - startMillis
    System.err.println(s"readDataInefficiently took ${time} milliseconds.")
    result
  }
}

因此你会发现,代码会变得不可读,计时功能干扰了实际的功能。运行这段代码将会发现其中一个方法花费的更多的时间来执行。

在下节中将会展示如何使用 AOP 来提升我们的代码。

使用 AOP

向前面看到的一样,向我们的方法中添加计时代码将会引入重复代码同时也使代码难以追踪,尽管是一个很小的例子。现在,假如我们同样需要打印一些日志或进行一些其他活动。AOP 将会帮助分离这些关注点。

我们可以把DataReadImpl重置到一开始的状态,这时它不再打印任何日志。现在创建另一个名为LoggingDataReader的特质,它扩展自DataReader并拥有以下内容:

trait LoggingDataReader extends DataReader {
  abstract override def readData(): List[Person] = {
    val startMillis = System.currentTimeMillis()
    val result = super.readData()
    val time = System.currentTimeMillis() - startMillis
    System.err.pringln(s"readData took $time milliseconds.")
    result
  }
  
  abstract override def readDataInefficiently():List[Person] = {
    val startMillis = System.currentTimeMillis()
    val result = super.readDataInefficiently()
    val time = System.currentTimeMillis() - startMillis
    System.err.println(s"readDataIneffieciently took $time milliseconds.")
    result
  }
}

这里有趣的地方在于abstract override修饰符。它提醒编译器我们会进行**叠加性(stackable)**的修改。如果我们不使用该修饰符,编译将会失败:

Error:(9, 24) method readData in trait DataReader is accessed from super. It may not be abstract unless it is overridden by a member declared `abstract' and `override'
	val result = super.readData()
					   ^

Error:(17, 24) method readDataInefficiently in trait DataReader is accessed from super. It may not be abstract unless it is overridden by a member declared `abstract' and `override'
val result = super.readDataInefficiently()
				   ^

现在让我们的新特质使用之前提到过的混入组合,在下面的程序中:

object DataReaderAOPExample extends App{
  val dataReader = new DataReaderImpl with LoggingDataReader
  System.out.println(s"I just read the following data efficiently: ${dataReader.readData()}") 
  System.out.println(s"I just read the following data inefficiently: ${dataReader.readDataInefficiently()}")
}

运行这段代码将会得到带有计时信息的输出。

使用 AOP 的优势是很明显的——实现不会被其他代码污染。再者,我们可以以同样的方式添加更多修改——更多的日志、重试逻辑、回滚等等。所有这些都可以通过创建一个新特质并扩展DataReader接口,然后在创建具体实现的实例中混入即可。当然,我们可以同时应用多个修改,它们将会按顺序执行,而顺序将会遵循线性化原则。

组件

组件作为应用的一部分意味着会与应用的其他部分进行结合。它们应该是可复用的,以便减少代码的重复。组件通常拥有接口,用于描述它们提供的服务或者它们依赖的一些服务或是其他组件。

在大型的应用中,我们通常会看到多个组件会被集成在一起工作。要描述一个组件提供的服务通常会很直接,这会使用接口的帮助来完成。与其他组件进行集成则可能需要开发者完成更多的工作。这通常会通过将需要的组件的接口作为参数来传递。然而,加入有一个大型的应用需要很多的组件;完成这些链接需要花费时间和精力。进一步,每次需要一个新的需求,我们也需要进行大量的重构。多重继承可以作为参数传递的替代方案;然而,首先需要语言支持这种方式。

像 Java 语言中用来链接组件的流行做法是使用依赖注入。Java 中拥有这样的库用于在运行时将组件注入。

丰富的 Scala

本书中我们已经提到多次,Scala 比简单的面向对象语言拥有更强的表现力。我们已经讨论了一些概念,比如:抽象类型、自类型、统一化、混入组合。这支持我们创建通用的代码,特定的类,并能以相同的方式来处理对象、类、变量或函数,并实现多重继承。使用不同的组合用法可以让我们编写期望的模块化代码。

实现组件

作为一个例子,假如我们尝试构建一个做饭机器人。我们的机器人能够查找食谱并制作我们需要的菜肴。我们可以通过创建新的组件来给机器人添加新的功能。

我们期望代码是模块化的,因此有必要对功能进行拆分。下面的图示展示了机器人的雏形以及各组件间的关系:

NAME

首先让我们给不同的组件定义接口:

trait Time{
  def getTime():String
}

trait RecipeFinder{
  def findRecipe(dish:String):String
}

trait Cooker{
  def cook(what:String): Food
}

这个例子中需要一个简单的Food类:

case class Food(name:String)

一旦这些完成后,我们就可以开始创建组件了。首先是TimeConponent,而Time的实现是一个嵌套类:

trait TimeConponent{
  val time:Time
  
  class TimeImpl extends Time{
    val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
    override def getTime():String = 
      s"The time is: ${LocalDateTime.now.format(formatter)}"
  }
}

现在以类似的方式实现RecipeComponent,下面是组件和实现的代码:

trait RecipeComponent{
  val recipe:RecipeFinder
  
  class RecipeFinderImpl extends RecipeFinder{
    override def findRecipe(dish:String):String = dish match {
      case "chips" => "Fry the potatoes for 10 minutes."
      case "fish" => "Clean the fish and put in the oven for 30 minutes."
      case "sandwich" => "Put butter, ham and cheese on the bread, toast and add tomatoes."
      case _ => throw new RuntimeException(s"${dish} is unknown recipe.")
    }
  }
}

最终,我们需要实现CookingComponent。它实际上会使用RecipeComponent,下面是它的实现:

trait CookingComponent{
  this: RecipeComponent =>
  
  val cooker:Cooker
  
  class CookerImpl extends Cooker {
	override def cook(what:String):Food = {
      val recipeText = recipe.findRecipe(what)
      Food(s"We just cooked $what using the following recipe: '$recipeText'.")
	}
  }
}

现在所有的组件都各自实现了,我们可以将它们组合来创建我们的机器人。首先创建一个机器人要使用的组件注册表:

class RobotRegisty extends TimeComponent with ReipeComponent with CookingComponent {
  override val time:Time = new TimeImpl
  override val recipe:RecipeFinder = new RecipeFinderImpl
  override val cooker:Cooker = new CookerImpl
}

现在创建机器人:

class Robot extends RobotRegisty{
  def cook(what:String) = cooker.cook(what)
  def getTime() = time.getTime()
}

最后使用我们的机器人:

object RobotExample extends App {
  val robot = new Robot
  System.out.println(robot.getTime()) 
  System.out.println(robot.cook("chips")) 
  System.out.println(robot.cook("sandwich"))
}

上面的例子中,我们看到了 Scala 不使用外部库来实现依赖注入的方式。这种方式真的很有用,它会避免我们的构造器过大,也不需要扩展过多的类。更进一步,各个组件可以很好的分离,可测试,并能清晰定义各自的依赖。我们同样看到了可以使用一些依赖其他组件的组件来递归的添加需求。

上面这个例子实际上展示了蛋糕模式。一个好的特性是,依赖的存在会在编译期间进行求值,而不像 Java 那些流行的库一样在运行时进行求值。

蛋糕模式同样也存在缺点,我们会在稍后关注所有特性——无论好坏。那里我们将会展示组件如何可以被测试。

这个蛋糕模式例子实质上很简单。在真是的应用中,我们可能需要一些组件依赖于其他组件,而那些组件有拥有各自的依赖。在这些情况下,事情会变得很复杂。我们将会本书的后续部分更好更详细的展示这一点。

总结

本章我们探讨了 Scala 中的 AOP,现在我们知道如何将那些本不需要出现在模块中的代码进行拆分。这将有效减少代码重复并使我们的程序拥有不同的专用模块。

我们同样展示了如何使用本书前面部分讨论的技术来创建可复用的组件。组件提供接口并拥有指定的需求,这可以很方便的使用 Scala 的丰富特性。这与设计模式很相关,应为它们拥有相同的目标——使代码更好,避免重复,易于测试。

本书的后续章节我们将会讨论一些具体的设计模式,及其有用特性和用法。我们将会以创建型模式开始,它们由四人帮(Gof)创建,当然,这里会以 Scala 的视角。

2.2.6 - CH06-创建型模式

创建型模式

从这章开始,我们将会深入到实际的实际模式当中。我们已经提到过了解并能正确使用现有的设计模式的重要性。

设计模式可以被认为是一些能够解决特定问题的最佳实践或甚至是模板。开发者需要处理的问题的数量是无穷尽的,有些情况下不同的设计模式不得不被组合使用。然而,基于编写一段代码来解决问题的程序的各个方面,我们可以将设计模式分为一下几个组要的组。

  • 创建型
  • 结构型
  • 行为型

本章将会关注于创建型设计模式,当然,我们将会以 Scala 语言的视角。我们将会贯穿以下一个主要的主题:

  • 什么是创建型设计模式
  • 工厂方法
  • 抽象工厂
  • 其他工厂设计模式
  • 单例
  • 构建器
  • 原型

在正式的定义这些创建型设计模式之后,我们将会更详细的单独研究其中的每一种。我们将会强调何时及如何使用它们,何时拒绝使用一些模式,当然,会展示一些相关的实例。

什么是创建型设计模式

创建型设计模式,和它名字描述的一样,用于处理对象的创建。有些情况下,对象的创建在程序中可能会引起一些额外的复杂性,而创建型模式隐藏这些复杂性以使软件组件的使用更加简单。对象创建的复杂性可能由下面任何一种原因引起:

  • 初始化参数的数量
  • 必要的验证
  • 捕获必要参数的复杂性

上面这个列表在有些情况下可能会变得更长,这些因素也不会单独出现,总是伴随而来。

我们将会在下个小节关注创建型设计模式的方方面面,希望你会对为何需要他们以及如何在实际生活中应用它们,有个好的理解。

2.2.7 - CH06-1-工厂方法

工厂方法模式用于封装实际类的初始化。它简单的提供一个借口来创建对象,然后工厂的子类来决定创建哪个具体的类。当我们需要在应用的运行时创建不同类的实例,这种模式将会很有用。这种模式同样可以有效的用于当需要开发者传入额外的参数时。

例子可以让一切变得简单,后面的小节我们将会提供一个实例。

类图

对于工厂方法,我们将会展示一个数据库相关的例子。为了使事情变得简单(因为实际的java.sql.Connection拥有很多方法),我们将会定义自己的SimpleConnection,它会拥有 MySQL 和 PostgreSQL 的具体实现。

这个连接类的类图看起来会像下面这样:

NAME

现在,连接的创建将会基于我们选择使用的数据库。然而,因为它们提供的接口,用法将会完全一样。实际的创建过程可能需要执行一些额外的、我们想要对用户隐藏的参数计算,而如果我们对每个数据库使用不同的常数,这些计算也会是相关的。这也就是为什么我们要使用工厂方法模式。下面的图示展示了剩余部分的代码将会如何被组织:

NAME

上面的图示中,MysqlClientPgsqlClient都是DatabaseClient的具体实现。工厂方法connect将会在不同的客户端中返回不同的实际连接。即便我们进行了覆写,代码中的签名仍然会显示返回一个SimpleConnection,但实际上会是具体类型。在这个类图中,为了清晰我们选择展示实际的返回类型。

代码实例

基于上面类图中清晰的展示,基于我们要使用的数据库客户端,一个不同的连接将会被创建和使用。让我们看一下这个类图的代码表示。首先是SimpleConnection以及其具体实现:

trait SimpleConnection{
  def getName():String
  def executeQuery(query:String):Unit
}

class SimpleMysqlConnection extends SimpleConnection{
  override def getName():String = "SimpleMySQLConnection"
  override def executeQuery(query:String):Unit = {
    System.out.println(s"Executing the query '$query' the MySQL way.")
  }
}

class SimplePgSqlConnection extends SimpleConnection {
  override def getName():String = "SimpePgSqlConnection"
  override def executeQuery(query:String):Unit = {
    System.out.println(s"Executing the query '$query' the PgSQL way.")
  }
}

对于这些实现的使用将会发生在工厂方法中,名为connect。下面的代码片段展示了我们可以如何利用连接,以及如何在特定的数据库客户端中实现。

// the factory
abstract class DatabaseClient {
  def executeQuery(query:String):Unit = {
    val connection = connect()
    connection.executeQuery(query)
  }
  
  // the factory method
  protected def connect():SimpleConnection
}

class MysqlClient extends DatabaseClient {
  override protected def connect():SimpleConnection = new SimpleMysqlConnection
}

class PgSqlClient extends DatabaseClient {
  override protected def connect():SimpleConnection = new SimplePgSqlConnection
}

然后可以直接使用这些客户端:

object Example extends App {
  val clientMysql:DatabaseClient = new MysqlClient
  val clientPgsql:DatabaseClient = new PgSqlClient
  
  clientMySql.executeQuery("SELECT * FROM users")
  clientPgSql.executeQuery("SELECT * FROM employees")
}

我们看到了工厂方法模式是如何工作的。如果我们需要添加另一个数据库客户端,则可以扩展DatabaseClient,并在实现connect方法时返回一个扩展自SimpleConnection的类。

上面例子中选择使用特质来定义 SimpleConnection,并使用抽象类来实现 DatabaseClient,这只是随机的选择。当然,我们完全可以使用特质来替代抽象类(除非需要参数构造器的时候)。

在有些场景中,由工厂方法创建的对象可能会在构造器中需要一些参数,而这些参数有可能基于持有工厂方法的对象的一些特定状态或功能。这也是该模式的实际闪光点。

Scala 中的替代选择

与软件工程中的任何事情一样,该模式同样可以由不同的方式来实现。不同的选择则要归结于需求的不同和应用、被创建对象的特定属性。一些可选的替代方法有:

  • 通过构造器向该类传入其需要的组件(对象组合)。这样一来可能意味着,每次请求,这些组件可能是特定的实例而非一个新的。
  • 传入一个能够创建我们所需要的实例的函数。

使用 Scala 的丰富特性,我们能够避免这种设计模式,我们能够更明智的知道,我们将要使用或暴露的这些对象是如何创建的,而无论工厂方法是如何被创建的。没有绝对对错的方式。然而,应该基于特定的需求来选择一种能够同时满足使用、维护更加简便的方式。

优点

和其他工厂一样,对象的创建细节被隐藏了。这意味着,如果我们改变了一个特定对象的创建方式,我们仅需要修改创建它的工厂方法(基于设计,这可能会涉及很多创建器)。工厂方法支持我们使用一个类的抽象版本并把对象的创建指派给子类。

缺点

在上面的例子中,如果我们拥有多于一个的工厂方法则很快会遇到问题。这首先需要开发者实现更多的方法,但更重要的是,这将导致返回的对象互不兼容。让我们看一个小例子。首先我们会定义另一个特质SimpleConnectionPrinter,拥有一个在被调用时打印一些东西的方法:

trait SimpleConnectionPrinter {
  def printSimpleConnection(connection:SimpleConnection):Unit
}

然后我们想改变我们的DatabaseClient并换个不同的名字:

abstract class BadDatabaseClient {
  def executeQuery(query:String):Unit = {
    val connection = connect()
    val connectionPrinter = getConnectionPrinter()
    connectionPrinter.printSimpleConnection(connection)
    connection.executeQuery(query)
  }
  
  protected def connect(): SimpleConnection
  protected def getConnectionPrinter(): SimpleConnectionPrinter
}

与原有例子不同的地方在于我们多了一个工厂方法,并在执行查询的时候同时被调用。类似于SimpleConnection的实现,现在让我们为SimpleConnectionPrinter创建另外两个具体实现:

class SimpleMysqlConnectionPrinter extends SimpleConnectionPrinter{
  override def printSimpleConnection(connection:SimpleConnection):Unit = {
    System.out.println(s"I require a MySQL connection. It is: '${connection.getName()}'")
  }
}

class SimplePgSqlConnectionPrinter extends SimpleConnectionPrinter{
  override def printSimpleConnection(connection:SimpleConnection):Unit = {
    SimpleConnection): Unit = { System.out.println(s"I require a PgSQL connection. It is: '${connection.getName()}'")
  }
}

现在我们可以应用工厂设计模式来创建 MySQL 和 PostgreSQL 客户端:

class BadMysqlClient extends BadDatabaseClient {
  override protected def connect():SimpleConnection = new SimpleMysqlConnection
  override protected def getConnectionPrinter(): SimpleConnectionPrinter = 
    new SimpleMySqlConnectionPrinter
}

class BadPgsqlClient extends BadDatabaseClient {
  override protected def connect(): SimpleConnection = new SimplePgSqlConnection
  override protected def getConnectionPrinter(): SimpleConnectionPrinter = 
    new SimpleMySqlConnectionPrinter	// note here
}

上面这个实现则有效完成了,现在我们可以使用它:

object BadExample extends App{
  val clientMySql: BadDatabaseClient = new BadMySqlClient
  val clientPgSql: BadDatabaseClient = new BadPgSqlClient
  clientMySql.executeQuery("SELECT * FROM users")
  clientPgSql.executeQuery("SELECT * FROM employees")
}

但是运行会得到如下结果:

I require a MySQL connection. It is 'SimpleMysqlConnection'
Execution the query 'SELECT * FROM users' the MySQL way.
I require a Mysql connection. It is 'SimplePysqlConnection'
Execution the query 'SELECT * FROM employees' the PgSQL way.

上面的例子显然发生了一个逻辑错误,同时也没有给我们提供任何提醒(在具体的工厂方法中使用了错误的具体实现)。随着要实现的方法数量的增长,这将会成为一个问题,并且错误会更易于发生。比如,我们的代码没有抛出任何异常,但是这种陷阱会引起运行时错误,这让问题变得难于发现和调试。

2.2.8 - CH06-2-抽象工厂

抽象工厂是工厂模式系列的另一种设计模式。其目的与所有工厂设计模式一样——封装对象的创建逻辑并对用户隐藏。不同在于它的实现方式。

抽象工厂设计模式基于对象组合,而非继承,像工厂方法模式的实现中则是基于继承。这里我们有一个单独的对象,它提供一个借口来创建我们需要的类的实例。

类图

这里我们仍然使用前面的SimpleConnection例子。下面的类图展示了抽象工厂是如何被组织的:

NAME

从上图中我们可以发现,现在我们可以拥有一个带有层级结构的工厂,而非数据库客户端中的一个方法。我们将会在应用中使用DatabaseConnectorFactory,同时它也会基于实际的实例类型返回正确的对象。

实例

让我们以代码的视角来观察我们的例子。下面的代码清单展示了工厂的层级结构:

trait DatabaseConnectionFactory{
  def connection():SimpleConnection
}

class MysqlFactory extends DatabaseConnectionFactory{
  override def connect():SimpleConnection = new SimpeMysqlConnection
}

class PgsqlFactory extends DatabaseConnectionFactory{
  override def connect():SimpleConnection = new SimplePgsqlConnection
}

然后我们可以将这些工厂传递给类并调用需要的方法来使用它们。这里有一个与之前展示的工厂方法类似的例子:

class DatabaseClient(connectionFactory:DatabaseConnectionFactory){
  def executeQuery(query:String):Unit = {
    connectionFactory.connect()
    connection.executeQuery(query)
  }
}

然后使用这个数据库客户端:

object Example extends App {
  val clientMysql:DatabaseClient = new DatabaseClient(new MysqlFactory)
  val clientPgsql:DatabaseClient = new DatabaseClient(new PgsqlFactory)
  
  clientMysql.executeQuery("SELECT * FROM users")
  clientPgsql.executeQuery("SELECT * FROM employees")
}

这就是抽象工厂的工作方式了。如果我们需要添加数据库客户端,只需要添加一个新的具体工厂来扩展DatabaseConnectionFactory。这给重构和扩展带来很大的遍历。

Scala 中的替代选择

这种设计模式同样可以以不同的方式实现。实际上,我们使用对象组合来将工厂传递给类也暗示着也可以使用别的方式:我们可以简单的传入一个函数,因为在 Scala 中它们也是统一化的一部分,它们会被与对象相同的方式处理。

优点

像所有工程模式一样,对象的创建细节被隐藏了。当我们想要暴露一系列对象时(比如数据库连接器),这种模式尤其有用。这时客户端会与具体类解耦。该模式通常会在一些 UI 工具集中最为示例展示,比如那些基于操作系统的不同而有所不同的元素。它也是测试友好的,因为我们可以给客户端提供一些模拟器而非实际的工厂。

尽管我们前面提过的不兼容问题在这里仍然存在,不过现在会很难遇到。主要是因为这里客户端实际上仅仅会传递一个单独的工厂作为参数。这种情况下我们为用户提供了一个具体的工厂,而编写这些工厂的时候一切都被静心安排过了。

缺点

如果我们使用的对象(这里是 SimpleConnection)和方法改变了签名,将会引起问题的出现。有些情况下,该模式可能会为我们的代码带来一些没有必要的复杂性,以及难以阅读和维护。

2.2.9 - CH06-3-其他工厂模式

还有一些工厂设计模式的变种。而在所有的场景中,它们的目的都是相同的——隐藏创建过程的复杂性。在下个小节中,我们将简短的提到另外两种工厂模式:静态工厂、简单工厂。

静态工厂

静态工厂可以被表示为一个静态方法,并作为基类的一部分。调用它来创建那些具体类的实例。这里最大的缺点之一是,如果给基类添加了另外一个扩展,基类也需要给修改,即修改这个静态方法。让我们展示一个动物世界中的例子:

trait Animal
class Bird extends Animal
class Mammal extends Ainmal
class Fish extends Animal

object Animal {
  def apply(animal:String):Animal = animal match {
    case "bird" => Bird
    case "mammal" => Mammal
    case "fish" => Fish
    case x:String => throw new RuntimeException(s"Unknown animal: $x")
  }
}

这样每次添加一种新的具体动物,我们不得不修改这个apply方法来包括它,特别是当我们要考虑新的类型时。

简单工厂

简单工厂比静态工厂要好一点,因为实际的工厂功能是在另一个类中。这使得每次扩展基类不必再去修改基类。类似于抽象工厂,但不同在于这里我们没有一个抽象工厂类,会直接使用具体工厂。通常从一个简单的工厂开始,随着时间的推移和项目的进化,最终会演变成一个抽象工厂。

工厂组合

当然,可以将不同的工厂模式组合在一起。当然,这种方式需要谨慎选择并且仅在有必要的时候使用。否则,设计模式的滥用将会导致烂代码。

2.2.10 - CH06-4-懒初始化

在软件工程中,懒初始化是是将对象或变量的初始化过程延迟直到第一次被需要使用的时候。这个想法的背后是想要延迟或避免昂贵操作的执行。

类图

在其他的语言中,比如 Java,懒初始化通常与工厂方法模式联合使用。该方法通常会检查我们需要使用的对象或变量是否已经被初始化了,如果没有则初始化该对象并返回。在后续的使用中,将会直接返回已被初始化的对象或变量。

Scala 语言中对懒初始化提供了内建支持,通过lazy关键字。因此在这里提供类图也没有意义了。

实例

让我们看一下懒初始化在 Scala 中是如何工作的,并证明它真的是“懒”的。我们会看一个求圆形面积的计算。我们知道,公式是pi * r² 。数学语言中已经提供了数学常量,我们在现实生活中也不会这么做。然而,如果我们讨论的是一个不为大家熟知常量,这个例子仍然是有关联的,或者基于一个值波动的常量,但是每天都会不同。

在学习里我们被教育 π 的值为 3.14。这是对的,然而,如果我们关心精度的话后面还有很多额外的小数,我们仍然要包括它们。比如,包含 100 个小数的 π:

3.14159265358979323846264338327950288419716939937510582097494459230781 64062862089986280348253421170679

因此让我们创建一个根据半径计算圆形面积的工具。我们将会把我们的 π 值作为变量放在工具类中,不过我们仍然支持用户来选择是否需要一个精确的面积值。如果需要,我们将会从配置文件中读取一个包含 100 个小时的 π 值:

object CircleUtils{
  val basicPi = 3.14
  lazy val precisePi:Double = {
    println("Reading properties for the precise PI.")
    val props = new Properties()
    props.load(getClass.getResourceAsStream("pi.properties"))
    props.getProperty("pi.high").toDouble
  }
  
  def area(redius:Double, isPrecise:Boolean = false):Double = {
    val pi:Double = if (isPrecise) precisePi else basicPi
    pi * Math.pow(radius, 2)
  }
}

基于对精度的需求,我们将会使用 π 的不同版本。这里的懒初始化是很有帮助的,因为我们可能永远都不需要计算一个精确的面积。或者有时需要有时不需要。更进一步,读取配置文件是一个 IO 操作,可以认为是一个慢的或者多次执行会产生不好的副作用的操作。现在来使用它:

object Example extends App {
  System.out.println(s"The basic area for a circle with radius 2.5 is ${CircleUtils.area(2.5)}")
  System.out.println(s"The precise area for a circle with radius 2.5 is ${CircleUtils.area(2.5, true)}")
  System.out.println(s"The basic area for a circle with radius 6.78 is ${CircleUtils.area(6.78)}") 
  System.out.println(s"The precise area for a circle with radius 6.78 is ${CircleUtils.area(6.78, true)}")
}

我们可以运行并观察程序的输出。首先,精度确实很重要,比如一些行业,金融、航天等等要重要的多。然后,在懒初始化块中,我们使用了一个print方法,它会在我们第一次使用精确实现的时候打印信息。普通的值会在实例创建的时候完成初始化。这显示了 Scala 中的懒初始化确实在第一次使用的时候才执行。

优点

当一个对象或变量的初始化过程很耗时或者可能用不着,懒初始化会尤其有用。有人会说我们可以简单的使用一个方法来实现,这也是对的。然而,假如一种场景,我们需要在我们的对象中以不同的调用从多个方法访问一个懒初始化的对象或变量。这种情况下,将结果保存在一个地方并复用它将会很有帮助。

缺点

在 Scala 之外的语言中,从多线程环境访问一个懒初始化的变量需要被小心管理。在 Java 中,需要使用synchronized来进行初始化。为了能够提供更好的安全性,应该优先使用double-checked locking,而 Scala 中则没有这种危险(?)。

2.2.11 - CH06-5-单例模式

单例模式用于确保一个类在整个应用中仅有一个实例。它在使用它的应用用引入了一个全局状态。

一个单例对象可以由不同的策略来初始化——懒初始化或热(eager)初始化。这都基于期望的用途,以及对象需要被初始化的时机,等等。

类图

单例是另一种由 Scala 语言语法所支持的设计模式。我们通过object关键字来实现它,并没有必要提供一个类图,因此我们下一小节直接进入一个实例。

实例

这个实例的目的是展示如果在 Scala 中创建一个单例对象,以及理解在 Scala 中对象是何时被创建的。我们将会看到一个名为StringUtils的类,提供了一些字符串相关的工具方法:

object StringUtils{
  def countNumberOfSpecs(text:String):Int = text.split("\\+s").length -1
}

这个类的用法也很直接。Scala 会管理对象的创建过程、线程安全,等等:

object UtilsExample extends App{
  val sentence = "Hello there! I am a utils example."
  println(s"The number of spaces in '$sentence' is:
  ${StringUtils.countNumberOfSpaces(sentence)}")
}

尽管StringUtils对象是一个单例实例,上面这个例子看起来仍然很清晰,类似于带有静态方法的类。这也就是在 Scala 中定义静态方法的方式。给一个单例类添加状态或许会更有意思。下面的例子展示了这种方式:

object AppRegistry{
  println("Registry initialization block called.")
  private val users: Map[String, String] = TrieMap.empty
  
  def addUser(id: String, name: String): Unit = { users.put(id, name) }
  def removeUser(id: String): Unit = { users.remove(id) }
  def isUserRegistered(id: String): Boolean = users.contains(id)
  def getAllUserNames(): List[String] = users.map(_._2).toList
}

AppRegistry包含一个使用应用的当前用户所构成的并发 Map。这是我们的全局状态,同时提供了一些方法来支持用户操作它。同时我们有一个打印语句,它会在这个单例对象被创建时执行。我们可以在下面的应用中使用这个注册表:

object AppRegistryExample extends App{
  System.out.println("Sleeping for 5 seconds.") 
  Thread.sleep(5000)
  System.out.println("I woke up.")
  AppRegistry.addUser("1", "Ivan")
  AppRegistry.addUser("2", "John") 
  AppRegistry.addUser("3", "Martin") 
  System.out.println(s"Is user with ID=1 registered? ${AppRegistry.isUserRegistered("1")}") 
  System.out.println("Removing ID=2") 
  AppRegistry.removeUser("2") 
  System.out.println(s"Is user with ID=2 registered? ${AppRegistry.isUserRegistered("2")}") 
  System.out.println(s"All users registered are: ${AppRegistry.getAllUserNames().mkString(",")}")
}

从运行这段代码得到的输出你会发现,在 “I woke up” 之后会打印单例对象被初始化的信息,因为在 Scala 中,单例会被懒初始化。

优点

在 Scala 中,单例模式与静态方法的实现方式一样。这也是单例可以有效的用于创建没有状态的工具类的原因。Scala 中的单例也可以用于创建 ADT。

另一个在 Scala 中严格有效的事情是,单例会被以线程安全的方式创建,因此无需一些额外的操作来确保。

缺点

单例模式实际上通常被称为是“anti-pattern”(反面模式、反面教材)。很多人说全局状态不能以单例的方式来实现。也有人说如果你不得不使用单例模式,则你需要尝试重构你的代码。虽然这在有些场景中是对的,但有些场景很适合使用单例模式。通常首要的原则是:如果你能避免,那就避免。

对于 Scala 的单例需要特别指出的一点是它可以真的仅拥有一个实例。虽然这实际上是该模式的定义,在其他语言中,我们可以拥有一个预定义的数量而非仅仅一个单例对象,我们可以使用自定义的逻辑进行控制。

有一点对 Scala 没有影响,不过还是值得一提。当应用中的单例被延迟初始化时,为了能够提供线程安全,需要基于一种加锁机制,比如前面提到过的double-checked locking。访问应用中的单例时,无论是在 Scala 中还是别的语言,同样需要以线程安全的方式来完成,或者由单例内部来处理这个问题。

2.2.12 - CH06-6-构建器模式

构建起模式支持以类方法而类构造器的方式来创建实例。当一个类的构造器拥有多个版本以支持不同的用途时,这种模式尤其有用。更进一步,有些情况下,创建所有的组合是不可能的或者它们也无法被了解。构建起模式使用一个额外的对象,称为builder,用于在创建最终版本的对象之前接收和保存初始化参数。

类图

这个小节中,我们首先会提供一个在其他语言中看起来比较经典的类图,包括 Java。然后,我们会基于它们来提供不同版本的代码实现来使其更符合 Scala,以及一些围绕它们的观察和讨论。

我们会拥有一个Person类,带有参数:firstName,lastName,age,departmentId等等。下个小节中会展示实际的代码。创建一个具体的构造器可能会花费太长时间,尤其是有些参数有些时候可能并不需要或被了解。这也会让代码在以后变得难以维护。而构建器模式可能会是个不错的想法,它的来图看起来像下面这样:

NAME

我们已经提到过,这也就是构建器模式在纯面向对象语言中看起来的样子。当构建器是抽象的时候,表示也会有所不同,这时会存在一些具体的构建器。这对它所创建的产品来说也是一样。最终,它们的目标都一样——使对象的创建更简单。

实例

实际上在 Scala 中有三种不同的方式来表示构建器模式:

  • 经典方式,像上面展示的类图,类似与其他编程语言。这种方式实际上是不推荐的,尽管在 Scala 中也能实现。为了实现它使用了可变性,这违背了语言的可不变原则。为了完整性和体现使用 Scala 的简单特性会使实现多么简单,我们会进行展示。
  • 使用带有默认参数的的样例类。我们会看到两种版本的实现,一种验证参数而另一种则没有。
  • 使用泛化的(generalized)类型约束。

下面的几个小节我们将会关注这些方式。为了使事情变得简单便于演示,我们会在类中使用较少的字段;然而,需要注意的是构建器模式真正适用的是拥有大量字段的类。

类似 Java 方式的实现

首先是 Person 类:

class Person(builder: PersonBuilder){
  val firstName = builder.firstName
  val lastName = builder.lastName
  val age = builder.age
}

该类接收一个 builder 并使用它被设置的值来初始化字段。下面是 Builder 的实现:

class PersonBuilder {
  var firstName = ""
  var lastName = ""
  var age = 0
  
  def setFirstName(firstName:String):PersonBuilder = {
    this.firstName = firstName
    this
  }
  
  def setLastName(lastName:String):PersonBuilder = {
    this.lastName = lastName
    this
  }
  
  def setAge(age:Int):PersonBuilder = {
    this.age = age
    this
  }
}

构建器中提供了方法用于设置与Person向对应的字段值。这些方法都会返回相同的构建器实例,这样就能支持我们链式的调用它们。下面是如何使用这个构建器:

object PersonBuilderExample extends App{
  val person:Person = new PersonBuilder()
    .setFirstName("Ivan")
    .setLastName("Nikolov")
    .setAge(26)
    .build()
  System.out.println(s"Person: ${person.firstName} ${person.lastName}. Age: ${person.age}.")
}

这就是构建器模式的使用方式。现在我们就可以创建一个Person对象而无论是否拥有需要提供的值——甚至仅拥有需要字段的一部分,我们可以指定一些字段然后剩余的字段会拥有默认的值。当为Person类添加新的字段也不必再创建新的构造器。仅需要通过PersonBuilder类进行兼容即可。

使用样例类实现

上个构建器模式的例子看起来很好,但是需要编写一些额外的代码和创建模板。此外,它需要我们在PersonBuilder类中拥有,这也违背了 Scala 中的不可变原则。

Scala 拥有样例类,这使得构建器模式的实现更加简单:

case class Person(
  firstName:String = "",
  lastName:String = "",
  age:Int = 0
)

其用法也与之前构建器类似:

object PersonCaseClassExample extends App {
  val person1 = Person(
    firstName = "Ivan",
    lastName = "Nikolov",
    age = 0
  )
  
  val person2 = Person(firstName = "John")
  
  System.out.println(s"Person 1: ${person1}") 
  System.out.println(s"Person 2: ${person2}")
}

这种实现要比前面第一种实现更加简短也更易维护。它能为开发者提供与第一种实现完全相同的功能,但是更简短、语法更清晰。同时保持了Person类的字段不可变,这也遵循了 Scala 中好的实践。

这两种实现都有的缺点是没有对参数进行验证。如果一些组件相互依赖并且有些特定的参数需要验证呢?前面这两种方式的用例中,我们可能会得到一些运行时异常。下一小节中将会展示如何确保验证和需求得到满足被实现。

使用泛化类型约束

在软件工程中的一些创建对象的用例中,我们会拥有一些依赖。我们要需要一些东西已经被初始化完成,那么需要一个特定的初始化顺序,等等。前面我们看到的两种构建器模式实现都缺乏确保某些东西已被实现或未被实现的能力。在这种方式中,我们需要围绕构建器模式创建一些额外的验证,以确保一切都符合预期。当然,我们会看一下在运行时创建对象是否是安全的。

使用一些我们之前在本书中见到的技术,我们可以创建一个能够在运行时验证所有的需求是否都已被满足的构建器。这杯称为type-safe builder,下个小节中我们会展示这种模式。

改变 Person 类

首先,我们将使用与 Java 实现方式中展示的例子中相同的类开始。现在添加一些约束,比如每个人都最少拥有firstNamelastName。为了能够让编译器感知到这些字段是否已被设置,需要将这些编码为一个类型。我们将使用 ADT 来达到这个目的。让我们定义下面的代码:

sealed trait BuildStep
sealed trait HasFirstStep extends BuildStep
sealed trait HasLastStep extends BuildStep

上面这些抽象类型定义了构建过程的不同步骤。现在对之前的Person类个构建器进行一些重构:

class Person(val firstName:String, val lastName:String, val age:Int)

我们将会使用Person类完整的构造器,而非传递一个构建器。这是为了展示构建实例并保持后续的步骤代码简介的另一种方式。这些改变需要PersonBuilder中的build方法也进行一些改变:

def build():Person = new Person(firstName, lastName, age)

改变 PersonBuilder 类

现在改变PersonBuilder的声明为如下方式:

class PersonBuilder[PassedStep <: BuildStep] private (
  var firstName:String,
  var lastName:String,
  var age:Int
)

这将要求之前所有那些返回PersonBuilder的方法现在返回PersonBuilder[PassedStep]。同时,这要限制不能够在使用new关键字来创建构建器了,因为构造器现在是私有的。现在添加一些构造器重载:

protected def this() = this("","",0)
protected def this(pb: PersonBuilder[_]) = this(
  pb.firstName,
  pb.lastName,
  pb.age
)

后面我们将会看到如何使用这些构造器。我们会支持用户以另一个方法来创建构建器,因为所有的构造器对外部都是不可见的。因此我们要添加一个伴生对象:

object PersonBuilder{
  def apply() = new PersonBuilder[BuildStep]()
}

伴生对象使用了我们前面定义的其中一个构造器,它同样确保了返回的对象用有正确的构建步骤。

给需要的方法添加泛化类型约束

到目前为止,我们所有拥有的仍然不能满足我们对每个 Person 对象初始化的要求。我们需要改变一些PersonBuilder类中的方法。下面是这些方法的定义:

def setFirstName(firstName:String):PersonBuilder[HasFirstName] = {
  this.firstName = firstName
  new PersonBuilder[HasFirstName](this)
}

def setLastName(lastName:String):PersonBuilder[HasLastName] = {
  this.lastName = lastName
  new PersonBuilder[HasLastName](this)
}

有趣的部分是最后的build方法,让我们首先看一下最初的额实现:

def build()(implicit ev:PassedStep =:= HasLastName):Person = 
  new Person(firstName, lastName, age)

上面的语法设置了一个泛化类型约束,它要求仅能在已经通过了HasLastName步骤的构建器上调用。看起来我们已经接近了预期的实现,但是现在的build方法仅适用于setLastName是四个方法中最后一个被调用的时候,同时也不会验证其他字段。让我们为setFirstNamesetLastName使用类似的方式并将它们链接起来,因此每个方法都会要求上一个方法已经被调用。下面是PersonBuilder的最终实现:

class PersonBuilder[PassedStep <: BuildStep] private (
  var firstName:String,
  var lastName:String,
  var age:Int
){
  protected def this() = this("", "", 0)
  protected def this(pb:PersonBuilder[_]) = this(
    pb.firstName,
    pb.lastName,
    pb.age
  )
  
  def setFirtstName(firstName:String):PersonBuilder[HasFirstName] = {
    this.firstName = firstName
    new PersonBuilder[HasFirstName](this)
  }
  
  def setLastName(lastName:String)(implicit ev:PassedStep =:= HasFirstName):PersonBuilder[HasLastName] = {
    this.lastName = lastName
    new PersonBuilder[HasLastName](this)
  }
  
  def setAge(age:Int):PsersonBuilder[PassedStep] = {
    this.age = age
    this
  }
  
  def build()(implicit ev: PassedStep =:= HasLastStep):Person = 
    new Person(firstName, lastName, age)
}

使用 type-safe 构建器

object PersonBuilderTypeSafeExample extends App {
  val person = PersonBuilder()
    .setFirstName("Ivan")
    .setLastName("Nikolov")
    .setAge(26)
    .build()
  System.out.println(s"Person: ${person.firstName} ${person.lastName}. Age: ${person.age}.")
}

如果我们遗漏了两个要求的方法之一或者颠倒了顺序,将会得到一个类似下面这样的编译错误:

Error:(103, 23) Cannot prove that com.ivan.nikolov.creational.builder.
type_safe.BuildStep =:=
com.ivan.nikolov.creational.builder.type_safe.HasFirstName.
		.setLastName("Nikolov")
		^

顺序的要求可以被认为是一个缺点,特别是根本不需要顺序的时候,不过可以通过一些额外的方式来解决这个问题。

对这个 type-safe 构建器的一些观察:

  • 使用 type-safe 构建器,我们可以限定一个指定的调用顺序,和一些已经被初始化的字段
  • 如果我们限定了多个字段,则需要将它们链接,这使得调用顺序变得重要。有些情况下会使库变得难用
  • 编译器消息,如果构建器没有被正确使用,这些消息并不能明确指示错误
  • 代码看起来跟 Java 方式的实现类似
  • 这种类似 Java 的实现方式导致了对并不推荐的可变性的依赖

Scala 支持我们使用一个很好很清晰的构建器模式实现,同时能够要求构建顺序和已被初始化的字段。这是一个很好的特性,尽管有时会变得冗长乏味,或者限制了方法的实际用途。

使用 require 语句

上面展示的 type-safe 构建器很好,不过仍然有一些缺点:

  • 复杂性
  • 可变性
  • 一个预定义的初始化顺序

然而,它有时可能会很有用,因为能够支持在编译期间检查所编写的代码。尽管有些时候编译期间的检查是没有必要的。如果这样的话,我们可以使事情变得非常简单并摆脱之前的复杂性,即使用已知的 case 类和require语句:

case class Person(firstName:String = "", lastName:String = "", age:Int = 0){ 
  require(firstName != "", "First name is required.")
  require(lastName != "", "Last name is required.")
}

如果上面的布尔条件不满足,我们的代码将会抛出一个IllegalArgumentException异常并包含正确信息。我们可以以同样的方式使用这些 case 类:

object PersonCaseClassRequireExample extends App{
  val person1 = Person(firstName = "Ivan", lastName = "Nikolov", age = 26)
  println(s"Person 1: ${person1}")
  try{
    val person2 = Person(firstName = "John")
  } catch {
    case e: Throwable => e.printStackTrace()
  }
}

我们发现这里变得更加简单了,字段也不再可变,实际上也没有任何特定的初始化顺序。更进一步,我们可以设置有意义的信息来帮助我们诊断潜在的问题。如果编译期间的验证并非必须,这可能是更好的方式。

优点

当我们需要创建一个复杂的对象时,这种构建器模式尤其适用,否则需要创建很多构造器。它通过一个 step-by-step 的方式使对象的创建更加简单、清晰、可读。

缺点

像我们在 type-safe 构建器中看到的,添加一些高级的逻辑和要求会带来相当多的工作。如果没有这种可能性,开发者会将危险暴露给这些类的用户以出现更多错误。同时,构建器包含很多看起来重复的代码,尤其是以类似 Java 的方式实现。

2.2.13 - CH06-7-原型模式

原型模式是一种从已存在的对象创建新对象的创建型模式。其目的在于避免昂贵的调用来保持高性能。

类图

在 Java 语言中,我们通常会看到一个类实现一个带有clone方法的接口,它返回一个该类的新实例。限免的图示展示了其类图:

NAME

实例

Scala 中原型模式的实现变得尤其简单。我们可以使用一种语言特性。由于原型设计模式真的类似于生物细胞分裂,让我们用一个细胞作为一个例子:

/** Represents a bio cell */
case class Cell(dna: String, proteins: List[String])

在 Scala 中,所有的 case 类都拥有一个copy方法,它会返回一个克隆自原有实例的新实例。并在复制的过程中改变一些值。下面是用法:

object PrototypeExample extends App{
  val initialCell = Cell("abcd", List("protein1", "protein2"))
  val copy1 = initialCell.copy()
  val copy2 = initialCell.copy()
  val copy3 = initialCell.copy(dna = "1234")
  System.out.println(s"The prototype is: ${initialCell}")
  System.out.println(s"Cell 1: ${copy1}")
  System.out.println(s"Cell 2: ${copy2}")
  System.out.println(s"Cell 3: ${copy3}")
  System.out.println(s"1 and 2 are equal: ${copy1 == copy2}")
}

优点

当性能很重要的时候原型模式将会很有帮助。使用copy方法,我们不用花费时间创建就能得到实例。缓慢可能源自一些创建过程中引起的计算,必须数据库调用或数据查询,等待。

缺点

使用对象的浅(shallow)拷贝可能引起错误或副作用,实际的引用会指向原始的实例。同时,避免构造器可能导致烂代码。原型模式需要被真正的用于不使用则会引起巨大的性能影响的场景。

2.2.14 - CH07-结构型模式

结构型设计模式

我们设计模式之旅的下一张将关注一系列结构型设计模式。我们将以 Scala 的视角探索以下结构型设计模式:

  • 适配器
  • 装饰器
  • 桥接
  • 组合
  • 门面
  • 享元
  • 代理

这一节我们将对什么是结构型设计模式以及它们为什么有用给出一个很好的理解。当我们熟悉了它们是什么之后,我们将单独的研究其中每一个并深入其细节,包括代码实例,何时使用、何时避免,以及在使用时需要注意什么。

什么是结构型设计模式

结构型设计模式关心于软件中对象与类的组合。它们使用不同的方式以获得新的、更大型的、通常也更复杂的结构。这些方式有一下几种:

  • 继承
  • 组合

能够正确识别软件中对象之间的关系是简化软件结构的关键。在下面的几个小节,我们将讨论几种不同的设计模式并提供一些实例,能够为如何使用不同的结构型设计模式找到一些感觉。

2.2.15 - CH07-1-适配器模式

在很多情况下,我们需要通过将不同的组件组合在一起来让应用跑起来。然而,通常会出现不同组件的接口互相不兼容的问题。类似于使用一些公共库或任何库,我们自己并不能对其进行修改——因为别人的意见跟我们目前的配置完全一样是不太常见的。这也就是适配器的用途所在。它的目的在于在不修改源代码的情况下帮助兼容各个接口以能够在一起工作。

我们将会在下面的小节中通过类图和实例来展示适配器是如何工作的。

类图

对于适配器的类图来说,让我们假如我们需要在应用中转换为使用一个新的日志库。我们尝试使用的新的库拥有一个log方法,它接收日志的消息和严重程度。然而,贯穿我们的整个应用,我们期望拥有info/debug/warning/error方法并且只接收日志消息,它能够自动的设置严重程度。当然,我们不能编辑原始库的代码,因此需要使用适配器模式。下面的图示展示了适配器模式的类图:

NAME

在上面的类图中,可以发现我们的适配器 AppLogger 扩展了 Logger,并使用其实例作为一个字段。在实现这些方法的时候,我们就可以简单的调用其log方法并传入不同的参数。这也就是通用的适配器实现方式,后面的小节我们会看到具体的代码实例。可能有些情况下不能直接扩展(比如原始类为 final),我们将展示 Scala 如何来处理这种问题。同时,我们也会展示一些推荐的用法来使用语言特性实现适配器模式。

实例

首先,首先看一下我们不能进行修改的Logger类的代码:

class Logger {
  def log(message:String, sevrvity:String):Unit = {
    System.out.println(s"${severity.toUpperCase}: $message")
  }
}

我们尽量保持简单以避免读者从本书的主旨上分心。下一步,我们可以要么仅仅编写一个类来扩展Logger,或者要么提供一个接口用于抽象。让我们使用第二种方式:

trait Log {
  def into(message:String):Unit
  def debug(message:String):Unit
  def warning(message:String):Unit
  def error(message:String):Unit
}

最终,我们可以创建AppLogger

class AppLogger extedns Logger with Log{
  overrride def log(messgae:String):Unit = log(message, "info")
  override def warning(message:String):Unit = log(message, "warning")
  override def error(message:String):Unit = log(message, "error")
  override def debug(message:String):Unit = log(message, "debug")
}

然后在我们的程序中使用:

object AdapterExample extends App {
  val logger: Logger = new AppLogger
  logger.info("This is an info message.") 
  logger.debug("Debug something here.") 
  logger.error("Show an error message.") 
  logger.warning("About to finish.") 
  logger.info("Bye!")
}

你会发现我们并没有完全按照类图的结构实现。我们并不需要 Logger 类的实例作为我们类的一个字段,因为我们的类已经是 Logger 的一个实例了(继承自 Logger),因此可以直接方法它的 log 方法。

这就是实现和使用基本的适配器模式的方法。然而,有些情况下我们想要适配的类被声明为 final,因此不能再对其进行扩展。下面的小节将展示如何处理这种情况。

final 类的适配器模式

如果我们声明原始日志类为 final,会发现之前实现的代码将编译错误。这种场景下有另一种适配器方式:

class FinalAppLogger extends Log {
  private val logger = new FinalLogger
  
  override def info(message: String): Unit = logger.log(message, "info")
  override def warning(message: String): Unit = logger.log(message, "warning")
  override def error(message: String): Unit = logger.log(message, "error")
  override def debug(message: String): Unit = logger.log(message, "debug")
}

这里,我们简单的将最终日志类的实例包装在一个类中,并调用它的方法来传入不同的参数。这种用法跟前面的实现完全一样。或者有一种变种是将最终日志类的实例作为构造器参数传入适配日志类。这在原始日志类的创建过程需要一些额外的参数时比较有用。

Scala 方式的适配器模式

之前我们已经提到过很多次,Scala 是一个特性丰富的语言。基于这样的事实,我们可以使用隐式类来实现适配器的功能。这里我们会使用前面例子中的定义的 FinalLogger 来演示。

隐式类会在可能的地方提供隐式转换。为了能使隐式转换生效,我们需要导入这些隐式声明,这也就是为何它们通常被声明在对象(object)或包对象(package-object)中。对于这个例子,我们将使用包对象:

package object adapter {
  implicit class FinalLoggerImplicit(logger:FinalLogger) extends Log{
    override def info(message: String): Unit = logger.log(message, "info")
    override def warning(message: String): Unit = logger.log(message, "warning")
    override def error(message: String): Unit = logger.log(message, "error")
    override def debug(message: String): Unit = logger.log(message, "debug")
  }
}

这是一个我们日志实例定义位置的包对象。它户自动将FinalLogger实例转换为我们的隐式类。下面的代码片段展示了如何使用它:

object AdapterImplicitExample extends App{
  val logger = new FinalLogger
  logger.info("This is an info message.")
  logger.debug("Debug something here.")
  logger.error("Show an error message.")
  logger.warning("About to finish.")
  logger.info("Bye!")
}

优点

适配器模式可以用于代码已经被设计并编写。它可以是接口协同工作或进行兼容。其实现和用法也很直接。

缺点

上面例子中的最后一种实现存在一些问题。就是每当我们需要使用这个日志器的时候总是需要导入包或对象。同时,隐式类或转换可能会让代码难以阅读和理解。隐式类也存在一些限制,描述在这:implicit-classes

像我们已经提到过的,当我们有一些无法改变的代码时该模式是有帮助的。如果我们能够修改原始代码,这将是最好的选择,因为在贯穿整个应用中使用适配器模式将会导致程序难以维护和理解。

2.2.16 - CH07-2-装饰器模式

有些情况下我们需要给应用中的类添加一些功能。这可以通过继承来完成;然而,我们并不像这么做以避免影响应用中的其他所有类。这也就是装饰器的用途。

装饰器的目的在于在不扩展原有类、并不影响其他扩展自该类的对象的行为的基础上为原有类添加额外的功能。

装饰器模式通过包装被装饰的类来工作,并可以在运行时应用。装饰器尤其适用于一个类拥有多个扩展,并且这些扩展会以不同的方式组合的场景。替代编写所有可能的组合,装饰器可以被创建并在每个之上叠加它们的修改。后面的几个小节我们将展示何时在真实的场景中使用装饰器。

类图

像我们之前看到的适配器设计模式,它的目标是将接口改变成不同的一个。在装饰器中,另一方面来说,帮助我们给方法提供额外的功能来增强一个接口。对于类图,我们将使用一个数据流的例子。假如我们拥有一个基本的流(stream),我们想要对其加密、压缩、替换字符等等。下面是类图:

NAME

在上面的类图中,AdvancedInputReaderInputReader提供了基本的实现。它包装了一个BufferedReader。然后我们拥有一个抽象的InputReaderDecorator类扩展自InputReader并拥有它的一个实例。通过扩展基本的装饰器,我们为流提供了加密、压缩或 Base64 编码它得到的输入的能力。我们可能在应用中想要不同的流,并且想要在上面的操作中以不同的顺序执行一个或多个。如果我们想要提供所有的可能性,代码将很快变得凌乱从而难以维护,尤其是当可能的操作数量很多。而使用装饰器,会在后面小节中看到的一样清晰整洁。

实例

让我们看一下描述上面类图的具体代码实例。首先,我们使用一个特质来定义InputReader接口:

trait InputReader {
  def readLines():Stream[String]
}

然后在AdvancedInputReader类中为接口提供一个基本的实现:

class AdvancedInputReader(reader:BufferedReader) extends InputReader {
  override def readLines():Stream[String] = 
    reader.readLines().iterator.asScala.toStream
}

为了能够应用装饰器模式,我们需要创建一些不同的装饰器。现在定义一个基本的装饰器:

abstract class InputReaderDecorator(inputReader:InputReader) extends InputReader{
  override def readLines():Stream[String] = inputReader.readLines()
}

然后拥有不同的装饰器实现。首先定义一个将所有文本转换为大写的装饰器:

class CapitalizedInputReader(inputReader:InputReader) extends InputReaderDecorator(inputReader) {
  override def readLines():Stream[String] = 
    super.readLines().map(_.toUpperCase)
}

再实现一个装饰器使用 gzip 分别将每一行输入进行压缩:

class CompressingInputReader(inputReader:InputReader) extends InputReaderDecorator(inputReader) {
  override def readLines():Stream[String] = super.readLines().map {
    case line =>
      val text = line.getBytes(Charset.forName("UTF-8"))
      logger.info("Length before compression: {}",text.length.toString)
      val output = new ByteArrayOutputStream()
      val compressor = new GZIPOutputStream(output)
      try {
        compressor.write(text, 0, text.length)
        val outputByteArray = output.toByteArray
        logger.info("Length after compression: {}",outputByteArray.length.toString)
        new String(outputByteArray, Charset.forName("UTF-8"))
      } finally {
        compressor.close()
        output.close()
      }
  }
}

最终,一个将所有行编码为 Base64 的装饰器:

class Base64EncoderInputReader(inputReader:InputReader) extends InputReaderDecorator(inputReader){
  override readLines():Stream[String] = super.readLines().map {
    case line =>
      Base64.getEncoder.encodeToString(line.getBytes (Charset.forName("UTF-8")))
  }
}

我们使用了一个中间的抽象类来演示装饰器模式,所有具体的装饰器都扩展自该抽象类。也可以不使用该中间抽象类而是直接扩展 InputReader 并包装其实例来实现这种模式。

现在则可以在应用中根据需要来使用这些装饰器给输入流增加功能。用法和直接,像下面这样:

object DecoratorExample extends App{
  val stream = new BufferedReader(
    new InputStreamReader(
      new BufferedInputStream(this.getClass.getResourceAsStream ("data.txt"))
    )
  )
  
  try{
    val reader = new CapitalizedInputReader(
      new AdvancedInputReader(stream)
    )
    reader.readLines().foreach(println)
  } finally {
    stream.close()
  }
}

上面的例子中我们使用了类路径中的文本文件,它有如下内如:

this is a data file 
which contains lines 
and those lines will be 
manipulated by our stream reader.

和预期一样,我们应用装饰器的顺序也定义了这个被增强的功能的顺序。

让我们看另一个例子,这次会应用所有的装饰器:

object DecoratorExampleBig extends App{
  val stream = new BufferedReader(
    new InputStreamReader(
      new BufferedInputStream(this.getClass.getResourceAsStream ("data.txt"))
    )
  )
  try{
    val reader = new CompressingInputReader(
      new Base64InputReader(
        new CapitalizedInputReader(
          new AdvancedInputReader(stream)
        )
      )
    )
    reader.readLines().foreach(println)
  } finally {
    stream.close()
  }
}

这个例子会读取文本、转换为大写、Base64 编码,最终压缩为 GZIP。

Scala 方式的装饰器模式

向其他设计模式一样,该模式也有一种利用 Scala 丰富特性的实现,其中使用了一些本书开头介绍的几种概念。在 Scala 中装饰器模式被称为特质叠加(stackable traits)。让我们看一下它的形式以及如何使用它。InputReaderAdvancedInputReader会像前面小节的实现一样被完全保留。我们实际是在两个例子中对其进行了复用。

下一步,不再定义一个abstract decorator类,取而代之的是仅仅在新的特质中定义不同的读取器修改:

trait CapitalizedInputReaderTrait extends InputReader {
  abstract override def readLines():Stream[String]
  super.readLines().map(_.toUpperCase)
}

然后,定义压缩输入读取器:

trait CompressingInuptReadrTrait extends InputReader with LazyLogging{
  abstract override def readLines():Stream[String]
  super.readLines().map{
    case line =>
      val text = line.getBytes(Charset.forName("UTF-8"))
      logger.info("Length before compression: {}", text.length.toString)
      val output = new ByteArrayOutputStream() 
      val compressor = new GZIPOutputStream(output)
      try {
        compressor.write(text, 0, text.length)
        val outputByteArray = output.toByteArray
        logger.info("Length after compression: {}", outputByteArray.length.toString)
        new String(outputByteArray, Charset.forName("UTF-8"))
      } finally {
        conpressor.close()
        output.close()
      }
  }
}

最后是 Base64 编码读取器:

trait Base64EncoderInputReaderTrait extends InputReader{
  abstract override def readLines(): Stream[String] = super.readlines.map{
    case line => 
      Base64.getEncoder.encodeToString(line.getBytes(Charset.forName("UTF-8")))
  }
}

你会发现这些实现并没有很大区别,这里我们使用特质替代了类,扩展自基本的InputReader特质,并使用了abstract override

抽象覆写(abstract override)支持我们在特质中对声明为 abstract 的方法调用 super。这允许在混入了一个已实现前一个方法的特质或类之后混入特质。abstract override 会告诉编译器我们是在故意这么做从而避免编译失败——它会在稍后进行检查,当我们使用特质并且其需要已经被满足的时候。

上面我们展示了两个例子,现在我们会展示使用堆叠特质又会是什么样子。首先是仅进行转换大写:

object StackableTraitsExample extends App{
  val stream = new BufferedReader(
    new InputStreamReader(
      new BufferedInputStream(this.getClass.getResourceAsStream ("data.txt"))
    )
  )
  
  try {
    val reader = new AdvancedInputReader(stream) with CapitalizedInputReaderTrait
    reader.readLines().foreach(println)
  } finally {
    stream.close()
  }
}

第二个例子包括转换大写、Base64 编码和压缩:

object StackableTraitsBigExample extends App{
  val stream = new BufferedReader(
    new InputStreamReader(
      new BufferedInputStream(this.getClass.getResourceAsStream ("data.txt"))
    )
  )
  
  try {
    val reader = new AdvancedInputReader(stream) with 
      CapitalizedInputReaderTrait with
      Base64EncoderInputReaderTrait with
      CompressingInputReaderTrait
    reader.readLines().foreach(println)
  } finally {
    stream.close()
  }
}

这两个例子的输出将会和原来的例子一样。然而这里我们使用了混入组合,看起来则会更加清晰。同时我们也少了一个类,因为我们不再需要abstract decorator类。要理解修改是如何被应用的也很简单——这里将会遵循叠加特质被混入的顺序。

叠加特质遵循线性化规则

事实上在我们当前的例子中,修改从左到右被应用会被误解。这会发生的原因是,我们会将调用推到栈上,直到我们抵达 readLines 的基本实现,然后再以反向的顺序应用修改。

在后续的章节中我们将会看到更加深入的例子。

优点

装饰器给我们的应用带了个更多灵活性。它们不会修改原有的类,因此不会向原有的代码引入错误,也会节省更多代码的编写和维护。同时,它们可以避免我们对所创建的类的应用场景有所遗忘或没有遇见到。

在上面的例子中,我们展示了一些静态行为修改。然而,同样能够在运行时对实例进行动态装饰。

缺点

我们已经覆盖了使用装饰器的正面影响;然而,同样要支持的是对装饰器的过度使用仍然会带来问题。最终我们会得到大量很小的类,这会使我们的库难以使用,对领域知识的要求也更高。它们也会使初始化过程变得复杂,会需要一些其他的(创建型)设计模式,比如工厂或构建器。

2.2.17 - CH07-3-桥接模式

有些应用中对一个特定的功能拥有多种不同的实现。这些实现可能是一组算法或者在多个平台上执行的操作。这些实现经常会发生变化,同时贯穿整个应用的声明周期中会添加有新的实现。更进一步,这些实现可能会以不同的方式应用于不同的抽象。像在这些场景下,更好的方式是从我们的代码中解耦,否则我们将面临类爆炸的危险。

桥接模式的目的在于将抽象与其实现解耦,然后二者可以互相独立地进行变动。

当抽象或实现经常会独立的进行变动时,桥接设计模式会很有帮助。如果我们直接实现一个抽象,对于抽象或实现的变动将总是会影响继承层级中的其他类。这将使扩展、修改、对类的独立复用变得难以进行。

桥接模式消除了直接实现抽象带来的问题,因此能够使抽象和实现易于复用和修改。

桥接模式与适配器模式非常类似,而它们的不同在于,前者是我们在设计的时候应用,而后者是在使用一些遗留代码或外部库时应用。

类图

对于类图和代码实例,让我们假设我们正在编写一个散列密码的库。在实践中,以普通文本的方式保存密码是需要避免的。这也是我们的库能够帮助用户的地方。有很多不同的散列算法可用。比如 SHA-1,MD5 和 SHA-256。我们想最少能够支持这些必能够轻松地添加新的方式。有不同的散列策略——你可以散列多次,组合不同的散列,给密码加盐等等。这些策略会让我们的密码很难通过*彩虹表(rainbow-table, 一种破解工具)*猜到。作为例子,我们将会展示带盐的散列和没有任何算的简单散列。

NAME

从上面的类图中你会发现,我们将实现(Hasher 及子类)和抽闲(PasswordConveter)进行了分离。这样就能支持我们添加新的散列实现,并在创建PasswordConveter的时候传入一个它的实例来立即使用它。如果我们没有之前的构建器模式,或许需要为每个散列算法分别创建一个密码转换器——这会使我们的代码规模膨胀或太过冗长而难以使用。

实例

现在让我们以 Scala 的视角看一下上面的类图。首先,我们会关注于实现这一边的Haser特质:

trait Hasher{
  def hash(data:String):String
  
  protected def getDigest(algorithm:String, data:String) = {
    val crypt = MessageDigest.getInstance(algorithm)
    crypt.reset()
    crypt.update(data.getBytes("UTF-8"))
    crypt
  }
}

然后我们有三个类来实现它。代码看起来很简单也很类似,只是返回的结果不同:

class Sha1Hasher extends Hasher {
  override def hash(data:String):String = 
    new String(Hex.encodeHex(getDigest("SHA-1", data).digest()))
}

class Sha256Hasher extends Hasher {
  override def hash(data:String):String = 
    new String(Hex.encodeHex(getDigest("SHA-256", data).digest()))
}

class Md5Hasher extends Hasher{
  override def hash(data:String):String = 
    new String(Hex.encodeHex(getDigest("MD5", data).digest()))
}

现在让我们看一下抽象这一边。这也就是客户端要使用的部分:

abstract class PasswordConverter(hasher:Hasher){
  def convert(password:String):String
}

我们选择提供两种不同的实现,SimplePasswordConverterSaltedPasswordConverter

class SimplePasswrodConverter(hasher:Hasher) extends PasswordConveter(hasher){
 override def convert(password:String):String = hasher.hash(password)
}

class SaltedPasswordConverter(hasher:Hasher) extends PasswordConverter(hasher){
 override def convert(password:String):String = hasher.hash(passwrod)
}

现在,如果客户端想要使用这个库,可以编写类似下面这样的代码:

object BridgeExample extends App {
  val p1 = new SimplePasswordConverter(new Sha256Hasher)
  val p2 = new SimplePasswordConverter(new Md5Hasher)
  val p3 = new SaltedPasswordConverter("8jsdf32T^$%", new Sha1Hasher)
  val p4 = new SaltedPasswordConverter("8jsdf32T^$%", new Sha256Hasher)
  
  System.out.println(s"'password' in SHA-256 is: ${p1.convert("password")}") 
  System.out.println(s"'1234567890' in MD5 is: ${p2.convert("1234567890")}") 
  System.out.println(s"'password' in salted SHA-1 is: ${p3.convert("password")}") 
  System.out.println(s"'password' in salted SHA-256 is: ${p4.convert("password")}")
}

Scala 方式的桥接模式

桥接模式是另一种能够通过使用 Scala 的强大语言特性来实现的模式。这里我们将使用自类型。最初的Hasher特质将会保持不变。而实现将会转为为特质而不再是类:

trait Sha1Hasher extends Hasher{
  override def hash(data: String): String = 
    new String(Hex.encodeHex(getDigest("SHA-1", data).digest()))
}

trait Sha256Hasher extends Hasher { 
  override def hash(data: String): String = 
    new String(Hex.encodeHex(getDigest("SHA-256", data).digest())) 
}

trait Md5Hasher extends Hasher { 
  override def hash(data: String): String = 
    new String(Hex.encodeHex(getDigest("MD5", data).digest())) 
}

使用特质将支持我们在后面需要的时候混入。

我们改变了例子中一些类的名字以避免冲突。PasswordConverter抽象现在看起来会像这样:

abstract class PasswordConverterBase {
  self: Hasher =>
  
  def convert(password:String):String
}

自类型会告诉编译器当我们使用PasswordConverterBase的时候同时需要混入一个Hasher

class SimplePasswordConverterScala extends PasswordConverterBase {
  self:Hasher =>
  
  override def convert(password:String):String = convert(password)
}

class SaltedPasswrodConverterScala(salt:String) extends PasswordConverterBase{
  self: Hasher =>
  
  override def convert(password:String):String = hash(s"${salt}:${password}")
}

最终,我们可以像下面这样使用新的实现:

object ScalaBridgeExample extends App{
  val p1 = new SimplePasswordConverterScala with Sha256Hasher
  val p2 = new SimplePasswordConverterScala with Md5Hasher
  val p3 = new SaltedPasswordConverterScala("8jsdf32T^$%") with Sha1Hasher
  val p4 = new SaltedPasswordConverterScala("8jsdf32T^$%") with Sha256Hasher
  
  System.out.println(s"'password' in SHA-256 is: ${p1.convert("password")}") 
  System.out.println(s"'1234567890' in MD5 is: ${p2.convert("1234567890")}") 
  System.out.println(s"'password' in salted SHA-1 is: ${p3.convert("password")}") 
  System.out.println(s"'password' in salted SHA-256 is: ${p4.convert("password")}")
}

运行这段代码将会与第一种实现得到的输出一样。然而,当我们使用我们的抽象时,可以混入我们需要使用的散列算法。但我们拥有更多的实现需要与散列结合时,这种优势会变得更加明显。使用混入看起来也会更加自然和易懂。

优点

像我们已经提到的,桥接模式类似于适配器模式,不过我们会在设计应用的时候应用该模式。使用它的一个明显优势是最终我们的应用中不会拥有爆炸数量的类,从而导致库的使用变得相当复杂。分离的层级结构也支持我们能够独立的扩展它们而不会影响其他的类。

缺点

桥接模式需要我们编写一些模板代码。当需要选择哪种的具体的实现时,会增加使用库的复杂性,或许使用桥接模式和一些创建型模式相结合或是一个不错的主意。总而言之,它没有主要的缺点,开发者需要根据当前的情况明智的选择是否使用这种模式。

2.2.18 - CH07-4-组合模式

组合设计模式用于描述一组对象应该像一个对象一样处理。

目的在于将对象组合为一个树结构以表示 整体-部分 的层级关系。

组合模式可用于移除重复代码或者在一组对象应该以相同的方式处理时避免错误。一个典型的例子是文件系统,我们可以拥有文件夹,其中又可以拥有其他文件夹或文件。通常,用于交互文件或文件夹的接口是相同的,因此它们可以很好的适用组合模式。

类图

像我们之前提到的,文件系统能够很好的适用组合模式。总的来说,它们是一个树形结构,因此对于我们的例子来说,我们将会展示如何使用组合模式来构建一个树形结构。

NAME

从上面的类图你会发现,Tree 是我们的组合对象。它包含子对象,要么是另一个带有子对象的 Tree 对象,要门仅仅是一个 Leaf 节点。

实例

让我们看一下上面类图的代码表示。首先我们需要通过一个特质来定义Node接口:

trait Node{
  def print(prefix:String):Unit
}

print 方法的 prefix 参数用于帮助在打印树结构时拥有更好的可视化。

在我们拥有接口之后,现在可以定义具体的实现:

class Leaf(data:String) extends Node {
  override def print(prefix:String):Unit = .println(s"${prefix}${data}")
}

class Tree extends Node{
  private val children = ListBuffer.empty[Node]
  
  override def print(prefix: String): Unit = {
    println(s"${prefix}(")
    children.foreach(_.print(s"${prefix}${prefix}"))
    println(s"${prefix})")
  }
  
  def add(child: Node): Unit = children += child
  def remove(): Unit = { 
    if (children.nonEmpty) children.remove(0)
  }
}

之后便可以直接使用这些代码。在打印的时候,我们不用关心它具体是叶子还是树。我们的代码将会自动处理这些:

object CompositeExample extends App{
  val tree = new Tree
  
  tree.add(new Leaf("leaf 1"))
  
  val subtree1 = new Tree 
  subtree1.add(new Leaf("leaf 2"))
  
  val subtree2 = new Tree 
  subtree2.add(new Leaf("leaf 3")) 
  subtree2.add(new Leaf("leaf 4")) 
  subtree1.add(subtree2)
  
  tree.add(subtree1)
  
  val subtree3 = new Tree 
  val subtree4 = new Tree 
  subtree4.add(new Leaf("leaf 5")) 
  subtree4.add(new Leaf("leaf 6"))
  
  subtree3.add(subtree4) 
  tree.add(subtree3)
  tree.print("-")
}

代码实际上会对我们的树形结构进行深度优先遍历。我们拥有的数据结构看起来会像下面这样:

NAME

优点

组合模式能够有效的减少代码重复,在创建层级结构的时候也很直接。简化的部分来自于客户端并不知道它们实际上正在处理的什么类型的对象。添加新的节点类型也很简单,不需要改变其他任何东西。

缺点

组合模式没有任何主要的缺点。它确实适用于具体场景。开发者需要注意的一点是在处理大规模的层级结构时,因为在这种情况下,我们可能会深入到递归签到的项目里,而这可能会引起栈溢出问题。

2.2.19 - CH07-5-外观模式

每当我们要构建一些库或大型的系统,总是会需要依赖一些其他的库或功能。方法的实现有时需要同时依赖多个类。这就需要一些必要的知识以了解这些类。无论何时我们为用户构建一些库,我们总是尝试使其对用户来说变得简单以假设他们并不会也并不需要像我们一样拥有广泛的领域知识。另外,开发者需要确保那些组件能够在整个用户的应用中易于使用。这也就是外观模式的用途所在。

其目的在于通过一个简单的结构来包装一个复杂的系统,以隐藏使用的复杂性来简化客户端的交互。

我们已经看到过一些基于包装来实现的设计模式。适配器模式通过将一个接口转换为另一个,装饰器用来添加额外的功能,而外观模式则使一切变得更加简单。

类图

对于类图,让我们假设一下的设置:我们想要用户能够从服务端下载一些数据并能以对象的方式完成反序列化。服务端以编码的形式返回数据,因此我们首先要对其进行解码,然后解析,最终返回正确的对象。所涉及的这些大量的操作使事情变的复杂。这也就是我们要使用外观模式的原因。

NAME

当客户端使用上面的应用时,他们仅需要与DataReader进行交互。而在内部会处理好对数据的下载、解码及反序列化。

实例

上面的类图中展示了DataDownloaderDataDecoderDataDeserializer 作为对象组合在DataReader中使用。这样实现起来直接清晰——它们要么可以通过默认的构造器创建,要么作为参数传入。对于我们例子的代码展示来说,我们选择使用特质来代替类,并将它们混入到DataReader类中。

首先让我们看一下这几个特质:

trait DataDownloader extends LazyLogging {
  def download(url:String):Array[Byte] = {
    logger.info("Downloading from: {}", url)
    Thread.sleep(5000)
    // { 
    // "name": "Ivan", 
    // "age": 26 
    // } 
    // the string below is the Base64 encoded Json above.
    "ew0KICAgICJuYW1lIjogIkl2YW4iLA0KICAgICJhZ2UiOiAyNg0KfQ==".getBytes
  }
}

trait DataDecoder{
  def decode(data: Array[Byte]): String = 
    new String(Base64.getDecoder.decode(data), "UTF-8")
}

trait DataDeserializer{
  implicit val formats = DefaultFormats
  
  def parse[T](data: String)(implicit m: Manifest[T]): T = 
    JsonMethods.parse(StringInput(data)).extract[T]
}

上面的这些实现十分简单并且相互分离,因为它们都处理不同的任务。任何人都可以使用它们;然而,这会需要一些必备的知识并使事情变得更加复杂。这也是为什么我们把外观类称为DataReader

class DataReader extends DataDownloader with DataDecoder with DataDeserializer{
  def readPerson(url:String):Person = {
    val data = download(url)
    val json = decode(data)
    parse[Person](json)
  }
}

这个实例清晰的展示了替代使用三个不同的接口,我们可以使用一个简单的方法调用。而所有的复杂性都隐藏在方法的内部。下面的清单展示了这个类的用法:

object FacadeExample extends App{
  val reader = new DataReader
  System.out.println(s"We just read the following person: ${reader.readPerson("http://www.ivan-nikolov.com/")}")
}

当然,在这个例子中,我们同样可以使用类来代替特质混入,这完全基于实现的需要。

优点

外观模式可以用于隐藏库的实现细节,使接口更加易于用来交互负载的系统。

缺点

人们常犯的一个错误是尝试把一切都放到一个外观中。这种形式是没有任何帮助的,开发者仍然要面对复杂的系统。另外,外观可能会被证明限制了那些拥有足够领域知识的用户对原始的功能的使用。当门面称为与内部系统交互的唯一方式时这种限制就尤为明显。

2.2.20 - CH07-6-享元模式

通常当软件编写完成后,开发者会尝试使其更加快速高效。通常这意味着更少的处理循环和更少的内存占用。有多种不同的方式来实现这两个概念。大多数时间,一个好的算法能够处理好第一个问题。内存的使用规模则存在不同的原因和解决方案,而享元模式则是用于帮助减少内存的使用。

该模式的目是通过将一个对象与其类似的对象共享尽可能多的数据来帮助优化内存的使用。

在很多情况下很多对象会共享一些相同的信息。讨论享元模式时一个常用的例子是单词处理。替代使用所有的信息包括字体、大小、颜色、图片等等来表示一个字符,我们可以仅仅保存类似字符的坐标并引用一个拥有相同信息的对象。这样可以使内存的使用显著减少。否则,这样的应用将无法使用。

类图

对于类图,让我们假设正在尝试表示一个类似下面这样用于色盲测试的图片:

NAME

我们可以发现,它由拥有不同大小和颜色的原型组合而成。可能的情况下,这可能是一个无限大的图片从而拥有无数个原型。为了使事情变的简单,让我们设置一个限制,假设仅拥有五种不同的颜色:红、绿、蓝、黄、洋红。下面的类图展示了如何使用享元模式来表示类似上面的图片:

NAME

实际的享元模式通过CircleFactoryCircleClient来实现。客户端请求工厂,要么返回一个新的Circle实例,要么是拥有相同参数的实例已经存在,则会从缓存中返回。比如,共享的数据是带有各自颜色的Circle对象,而每个特定的Circle都拥有各自的坐标和半径。Graphic中会包含带有所有信息的圆形。基于上面类图的代码实现会使一切变得更加清晰。

实例

是时候看一下使用 Scala 来表示享元模式是什么样子了。我们将使用前面展示过的相同的例子。如果代码版本中的名称等属性与类图中有所不同,并没有什么值得注意的。这种变化主要是源自 Scala 的习俗。在贯穿代码的时候我们将会在发生的地方明确指出。

关于享元模式和例子中有趣的一点是我们实际上使用了一些前面提到过的模式和技术。我们同样会在贯穿代码的时候明确指出。

首先我们要做的是表示颜色。这实际上跟模式无关,这里我们使用 ADT 来表示:

sealed abstract class Color
case object Red extends Color
case object Blue extends Color
case object Green extends Color
case object Yellow extends Color
case object Magenta extends Color

在定义完颜色之后,我们开始定义Circle类:

class Circle(color:Color){
  System.out.println(s"Creating a circle with $color color.")
  
  override def toString(): String = s"Circle($color)"
}

这些圆形将会是享元对象,因此对象中仅拥有将会与其他对象共享的数据。现在我们拥有了圆形的模型,现在创建圆形工厂。这里使用了工厂模式:

object Circle{
  val cache = Map.empty[Color, Circle]
  
  def apply(color: Color): Circle = 
    cache.getOrElseUpdate(color, new Circle(color))
    
  def circlesCreated(): Int = cache.size
}

我们使用 Scala 中的伴生对象来实现工厂模式。这也就是为什么类名与上面类图中的类名不同。这种表示支持我们使用下面的语法要么获得一个已存在的实例要么创建一个新的实例:

Circle(Green)

现在我们拥有了圆形和工厂,然后实现Graphic类:

class Graphic{
  val items = ListBuffer.empty[(Int,Int,Double,Circle)]
  
  def addCircle(x:Int, y:Int, radius:Double, circle:Circle):Unit = {
    items += ((x,y,radius,circle))
  }
  
  def draw():Unit = {
    items.foreach {
      case (x, y, radius, circle) =>
        System.out.println(s"Drawing a circle at ($x, $y) with radius $radius: $circle")
    }
  }
}

Graphic类实际上将会包含所有其他与圆形相关的数据。上面类图中的Client并不需要在代码中进行明确的表示——它仅仅是使用工厂的代码。类似的,Graphic对象会被程序来搜索圆形对象,而非通过一个客户端的显式访问。下面是对这些类的使用:

object FlyweightExample extends App{
  val graphic = new Graphic
  graphic.addCircle(1, 1, 1.0, Circle(Green)) 
  graphic.addCircle(1, 2, 1.0, Circle(Red)) 
  graphic.addCircle(2, 1, 1.0, Circle(Blue)) 
  graphic.addCircle(2, 2, 1.0, Circle(Green)) 
  graphic.addCircle(2, 3, 1.0, Circle(Yellow)) 
  graphic.addCircle(3, 2, 1.0, Circle(Magenta)) 
  graphic.addCircle(3, 3, 1.0, Circle(Blue)) 
  graphic.addCircle(4, 3, 1.0, Circle(Blue)) 
  graphic.addCircle(3, 4, 1.0, Circle(Yellow)) 
  graphic.addCircle(4, 4, 1.0, Circle(Red))

  graphic.draw()

  System.out.println(s"Total number of circle objects created: ${Circle.circlesCreated()}")
}

在一开始定义Circle类的时候,我们会在其构造期间打印一条信息。运行这段代码会发现,带有每个特定颜色的圆形仅会被创建一次,尽管我们在构件整个图的时候使用了很多圆形。最后一行信息显示了我们事实上仅拥有 5 个实际的圆形实例,尽管我们的图拥有十个不同的圆形。

该实例仅仅展示了享元模式是如何工作的。在真实的应用中,享元对象可能会共享更多的信息,从能能够在实际的应用中减少内存的占用。

优点

像我们前面已经提到的,享元模式可以用于尝试减少应用的内存占用。使用共享的对象,我们的应用会需要更少对象的构造和解构,同时也能提高性能。

缺点

基于所要共享数据的规模,有时实际共享对象的数量会动态的增长而不能带来更多的优势。另外,会增加工长本身和其用法的复杂性。多线程应用则需要对工厂进行额外的处理。最后但并非最不重要的是,开发者在使用共享对象时需要额外小心,其中的任何改变都会影响到整个应用,在 Scala 中由于不可变性则不需要额外关心。

2.2.21 - CH07-7-代理模式

2.2.22 - CH08-行为型模式-1

2.2.23 - CH09-行为型模式-2

2.2.24 - CH10-函数式模式理论

2.2.25 - CH11-函数式模式应用

2.2.26 - CH12-实际应用

3 - 微服务架构

3.1 - 模式概览

设计目标

  • 缩减成本:降低设计、实现和维护 IT 服务的总体成本。
  • 加快发布:加快服务从想法到部署的落地速度。
  • 增强弹性:提升服务网络的弹性。
  • 开启可见性:支持为服务和网络提供更好的可见性。

设计原则

  • 可扩展性
  • 可用性
  • 韧性
  • 灵活性
  • 自治性—独立自主性
  • 去中心化治理
  • 故障隔离
  • 自动装配
  • 持续交付—基于 DevOps

模式概览

NAME

分解

按业务功能分解

说白了,微服务就是要应用单一职责原则,把服务改造成松耦合式的。它可以按照业务功能进行分解。定义和业务功能相对应的服务。业务功能是一个来自业务架构建模 [2] 的概念。它是一个企业为了创造价值而要去做的某些事情。一个业务功能往往对应于一个业务对象,比如:

  • 订单管理负责订单
  • 客户管理则是负责客户

按问题子域分解

按照业务功能来分解一个应用程序可能会是一个不错的开始,但是你终将会遇到所谓的“神类”,它很难再被分解。这些类将在多个服务之间都是通用的。可以定义一些和领域驱动设计(DDD)里面的子域相对应的服务。DDD 把应用程序的问题空间 —— 也即是业务 —— 称之为域。一个域由多个子域组成。每个子域对应业务的各个不同部分。

子域可以分为如下几类:

  • 核心:业务的核心竞争力以及应用程序最有价值的部分
  • 支撑:和业务有关但并不是一个核心竞争力。这些可以在内部实现也可以外包
  • 通用:不特定于业务,而且在理想情况下可以使用现成的软件实现

比如一个订单管理的子域包括:

  • 产品目录服务
  • 库存管理服务
  • 订单管理服务
  • 配送管理服务

按事务/2PC模式分解

你可以通过事务分解服务。然后,这样一来系统里将会存在多个事务。事务处理协调器[3]是分布式事务处理的重要参与者之一。分布式事务包括两个步骤:

  • 准备阶段 —— 在这个阶段,事务的所有参与者都准备提交并通知协调员他们已准备好完成事务
  • 提交或回滚阶段 —— 在这个阶段,事务协调器向所有参与者发出提交或回滚命令

2PC 的问题在于,和单个微服务的运行时间相比,它显得相当慢。即便这些微服务跑在相同的网络里,它们之间的事务协调也确实会减慢系统速度,因此这种方法通常不适用于高负载情况。

绞杀者模式:Strangler Pattern

上面三种,我们看到的这几个设计模式都是用来分解绿场(Greenfield)的应用程序,但是往往我们所做的工作中有 80% 是针对灰场(Brownfield)应用程序,它们是一些大型的单体应用程序(历史遗留的代码库)。

绞杀者模式可以解决这类问题。它会创建两个单独的应用程序,它们并排跑在同一个 URI 空间里。随着时间的流逝,直到最后,新重构的应用程序会“干掉”或替换原有的应用程序,此时就可以关掉那个老的单体应用程序。绞杀应用程序的步骤分别是转换、共存和消除[4]:

  • 转换:Transform,使用现代方法创建一个并行的全新站点。
  • 共存:Coexist,让现有站点保留一段时间。把针对现有站点的访问重定向到新站点,以便逐步实现所需功能。
  • 消除:Eliminate,从现有站点中删除旧功能。

隔仓模式:Bulkhead Pattern

让一个应用程序的元素和池子相对隔离,这样一来,其他应用程序将可以继续正常工作。这种模式被称为“隔舱”,因为它类似于船体的分段分区。根据使用者负载和可用性要求,将服务实例分成不同的组。这种设计有助于隔离故障,并允许用户即使在故障期间仍可为某些使用者维持服务。

边车模式:Sidecar Pattern

该模式将一个应用程序的组件部署到一个单独的处理器容器里以提供隔离和封装。它还允许应用程序由异构的组件和技术组成。这种模式被称为边车模式(Sidecar),因为它类似于连接到摩托车的侧边车。在该模式中,侧边车会附加到父应用程序,并为该应用程序提供功能支持。Sidecar 还与父应用程序共享相同的生命周期,并与父应用程序一起创建和退出。Sidecar 模式有时也称为 sidekick 模式,这是我们在文章中列出的最后一个分解模式。

集成

网关模式:API Gateway

当一个应用程序被分解成多个较小的微服务时,这里会出现一些需要解决的问题:

  • 存在不同渠道对多个微服务的多次调用
  • 需要处理不同类型的协议
  • 不同的消费者可能需要不同的响应格式

API 网关有助于解决微服务实现引发的诸多问题,而不仅限于上述提到的这些。

  • API 网关是任何微服务调用的单一入口点
  • 它可以用作将请求路由到相关微服务的代理服务
  • 它可以汇总结果并发送回消费者
  • 该解决方案可以为每种特定类型的客户端创建一个细粒度的 API
  • 它还可以转换协议请求并做出响应
  • 它也可以承担微服务的身份验证/授权的责任。

聚合器模式:Aggregator Pattern

将业务功能分解成几个较小的逻辑代码段后就有必要考虑如何协同每个服务返回的数据。不能把这个职责留给消费者。

聚合器模式有助于解决这个问题。**它讨论了如何聚合来自不同服务的数据,然后将最终响应发送给消费者。**这里有两种实现方式[6]:

  1. 一个组合微服务将调用所有必需的微服务,合并数据,然后在发送回数据之前对其进行转换合成。
  2. 一个 API 网关还可以将请求划分成多个微服务,然后在将数据发送给使用者之前汇总数据。

如果要应用一些业务逻辑的话,建议选择一个组合式的微服务。除此之外,API 网关作为这个问题的解决方案已经是既定的事实标准。

代理模式

针对 API 网关,我们只是借助它来对外公开我们的微服务。引入 API 网关后,我们得以获得一些像安全性和对 API 进行分类这样的 API 层面功能。在这个例子里,API 网关有三个 API 模块:

  1. 移动端 API,它实现了 FTGO 移动客户端的 API

  2. 浏览器端 API,它实现了在浏览器里运行的 JavaScript 应用程序的 API

  3. 公共API,它实现了一些第三方开发人员需要的 API

网关路由模式

API 网关负责路由请求。一个 API 网关通过将请求路由到相应的服务来实现一些 API 操作。当 API 网关接收到请求时,它会查询一个路由映射,该路由映射指定了将请求路由到哪个服务。一个路由映射可以将一个 HTTP 方法和路径映射到服务的 HTTP URL。这种做法和像 NGINX 这样的 Web 服务器提供的反向代理功能一样。

链式服务模式

单个服务或者微服务将会有多级依赖,举个例子:Sale 的微服务依赖 Product 微服务和 Order 微服务。链式微服务设计模式将帮助你提供合并后的请求结果。microservice-1 接收到请求后,该请求随后与 microservice-2 进行通信,还有可能正在和 microservice-3 通信。所有这些服务都是同步调用。

分支模式

一个微服务可能需要从包括其他微服务在内的多个来源获取数据。分支微服务模式是聚合器和链式设计模式的混合,并允许来自两个或多个微服务的同时请求/响应处理。调用的微服务可以是一个微服务链。分支模式还可用于根据你的业务需求调用不同的微服务链或单个链。

客户端UI组合模式

通过分解业务功能/子域来开发服务时,负责用户体验的服务必须从多个微服务中提取数据。在一个单体世界里,过去只有一个从 UI 到后端服务的调用,它会检索所有数据然后刷新/提交 UI 页面。但是,现在不一样了。

对于微服务而言,我们必须把 UI 设计成一个具有屏幕/页面的多个板块/区域的框架。每个板块都将调用一个单独的后端微服务以提取数据。诸如 AngularJS 和 ReactJS 之类的框架可以帮助我们轻松地实现这一点。

这些屏幕称为单页应用程序(SPA)。每个团队都开发一个客户端 UI 组件,比如一个 AngularJS 指令,该组件实现其服务的页面/屏幕区域。UI 团队负责通过组合多个特定服务的 UI 组件来实现构建页面/屏幕的页面框架。

数据库

给微服务定义数据库架构时,我们需要考虑以下几点:

  1. 服务必须是松耦合的。这样它们可以独立开发,部署和扩展

  2. 业务事务可能会强制跨越多个服务的不变量

  3. 一些业务事务需要查询多个服务的数据

  4. 为了可扩展性考虑,数据库有时候必须是可复制和共享的

  5. 不同服务存在不同的数据存储要求

每个服务一套数据库

为了解决上述问题,必须为每个微服务设计一个数据库。它必须仅专用于该服务。应当只能通过微服务的 API 访问它。其他服务无法直接访问它。比如,针对关系型数据库,我们可以采用每个服务使用单独的专用表(private-tables-per-service),每个服务单独的数据库模式(schema-per-service)或每个服务单独的数据库服务器(database-server-per-service)。

服务间共享数据库

我们已经说过,在微服务里,为每个服务分配一套单独的数据库是理想方案。采用共享数据库在微服务里属于反模式。但是,如果应用程序是一个单体应用而且试图拆分成微服务,那么反正规化就不那么容易了。在后面的阶段里,我们可以转到每个服务一套数据库的模式,直到我们完全做到了这一点。服务之间共享数据库并不理想,但是对于上述情况,它是一个切实可行的解决方案。大多数人认为这是微服务的反模式,但是对于灰场应用程序,这是将应用程序分解成更小逻辑部分的一个很好的开始。值得一提的是,这不应当应用于绿场应用程序。

命令和查询职责分离 (CQRS)

一旦实现了每个服务分配单独一套数据库(database-per-service),自然就会产生查询需求,这需要联合来自多个服务的数据。然而这是不可能的。CQRS 建议将应用程序分成两部分 —— 命令端和查询端。

  • 命令端处理创建,更新和删除请求
  • 查询端通过使用物化视图来处理查询部分

这通常会搭配事件驱动模式(event sourcing pattern)一起使用,一旦有任何数据更改便会创建对应的事件。通过订阅事件流,我们便可以让物化视图保持更新。

事件驱动

绝大多数应用程序需要用到数据,典型的做法就是应用程序要维护当前状态。例如,在传统的创建,读取,更新和删除(CRUD)模型中,典型的数据流程是从存储中读取数据。它也包含了经常使用事务导致锁定数据的限制。

事件驱动模式[7]定义了一种方法,用于处理由一系列事件驱动的数据操作,每个事件都记录在一个 append-only 的存储中。应用程序代码向事件存储发送一系列事件,这些事件命令式的描述了对数据执行的每个操作,它们会被持久化到事件存储。每个事件代表一组数据更改(例如,AddedItemToOrder)。

这些事件将保留在充当记录系统的一个事件存储里。事件存储发布的事件的典型用途是在应用程序触发的一些动作更改实体时维护这些实体的物化视图,以及与外部系统集成。例如,一个系统可以维护一个用于填充 UI 部分所有客户订单的物化视图。当应用程序添加新订单,添加或删除订单中的项目以及添加运输信息时,描述这些更改的事件将会得到处理并用于更新物化视图。下图展示了该模式的一个概览。

NAME

Saga 模式

当每个服务都有它们自己的数据库,并且一个业务事务跨越多个服务时,我们该如何确保各个服务之间的数据一致性呢? 每个请求都有一个补偿请求,它会在请求失败时执行。这可以通过两种方式实现:

  • 编舞(Choreography) —— 在没有中央协调的情况下,每个服务都会生成并侦听另一个服务的事件,并决定是否应该采取措施。编舞是一种指定两个或多个参与方的方案。任何一方都无法控制对方的流程,或者对这些流程有任何可见性,无法协调他们的活动和流程以共享信息和值。当需要跨控制/可见性域进行协调时,请使用编舞的方式。参考一个简单场景,你可以把编舞看作和网络协议类似。它规定了各方之间可接受的请求和响应模式。

    NAME
  • 编排(Orchestration) —— 一个编排器(对象)会负责 saga 的决策和业务逻辑排序。此时你可以控制流程中的所有参与者。当它们全部处于一个控制域时,你可以控制该活动的流程。当然,这通常是你被指派到一个拥有控制权的组织里制定业务流程。

    NAME

可观测性

日志聚合

考虑一个应用程序包含多个服务的用例。请求通常跨越多个服务实例。每个服务实例均采用标准格式生成日志文件。我们需要一个集中式的日志记录服务,该服务可以汇总每个服务实例的日志。用户可以搜索和分析日志。他们可以配置在某些消息出现在日志中时触发告警。例如,PCF 就有日志聚合器,它在应用侧从 PCF 平台的每个组件(router、controller、diego等)收集日志。AWS Cloud Watch 也是这样做的。

性能指标

当服务组合由于引入了微服务架构而增加时,保持对事务的监控就变得尤为关键了,如此一来就可以监控这些模式,而当有问题发生时便会发送告警。

此外,需要一个度量服务来收集有关单个操作的统计信息。它应当聚合一个应用服务的指标数据,它会用来报告和告警。这里有两种用于汇总指标的模型:

  • 推送 —— 服务将指标推送到指标服务,例如 NewRelic,AppDynamics
  • 提取 —— 指标服务从服务中提取指标,例如 Prometheus

分布式链路追踪

在微服务架构里,请求通常跨越多个服务。每个服务通过跨越多个服务执行一个或多个操作来处理请求。在排障时,有一个 Trace ID 是很有帮助的,我们可以端对端地跟踪一个请求。

解决方案便是引入一个事务ID。可以采用如下方式:

  • 为每个外部请求分配一个唯一的外部请求ID
  • 将外部请求ID传递给处理该请求链路的所有服务
  • 在所有日志消息中加入该外部请求ID

健康检查

实施微服务架构后,服务可能会出现启动了但是无法处理事务的情况。每个服务都需要有一个可用于检查应用程序运行状况的 API 端点,例如 /health。该 API 应该检查主机的状态,与其他服务/基础设施的连接以及任何其他特定的逻辑。

横切关注点

外部配置

一个服务通常还会调用其他服务和数据库。对于dev,QA,UAT,Prod等每个环境而言,API 端点的 URL 或某些配置属性可能会有所不同。这些属性中的任何一个更改都可能需要重新构建和重新部署服务。

为避免代码修改,可以使用配置。把所有配置放到外面,包括端点 URL 和证书。应用程序应该在启动时或运行时加载它们。这些可以在启动时由应用程序访问,也可以在不重新启动服务器的情况下进行刷新。

服务发现模式

在微服务出现时,我们需要在调用服务方面解决一些问题。

借助容器技术,IP地址可以动态地分配给服务实例。每次地址更改时,消费端服务都会中断并且需要手动更改。

对于消费端服务来说,它们必须记住每个上游服务的 URL ,这就变成紧耦合了。

为此,需要创建一个服务注册中心,该注册表将保留每个生产者服务的元数据和每个服务的配置。服务实例在启动时应当注册到注册中心,而在关闭时应当注销。服务发现有两种类型:

  • 客户端:例如:Netflix Eureka
  • 服务端:例如:AWS ALB
NAME

熔断器模式

一个服务通常会通过调用其他服务来检索数据,而这时候下游服务可能已经挂了。这样的话,有两个问题:首先,请求将继续抵达挂了的服务,耗尽网络资源,并且降低性能。其次,用户体验将是糟糕且不可预测的。

消费端服务应通过代理来调用远程服务,该代理的表现和一个电流断路器类似。当连续的故障数超过阈值时,断路器将跳闸,并且在超时期间内,所有调用远程服务的尝试都会立即失败。超时到期后,断路器将允许有限数量的测试请求通过。如果这些请求成功,断路器则将恢复正常运行。否则,如果发生故障的话,超时时间则将再次重新开始计算。如果某些操作失败概率很高的话,采取此模式有助于防止应用程序在故障发生后仍然不断尝试调用远程服务或访问共享资源。

NAME

蓝绿部署模式

使用微服务架构时,一个应用可以被拆分成许多个微服务。如果我们采用停止所有服务然后再部署改进版本的方式的话,宕机时间将是非常可观的,并且会影响业务。同样,回滚也将是一场噩梦。 蓝绿部署模式可以避免这种情况。

实施蓝绿部署策略可以用来减少或消除宕机。它通过运行两个相同的生产环境,Blue 和Green 来实现这一目标。 假设 Green 是现有的活动实例,Blue 是该应用程序的新版本。在任何时候,只有一个环境处于活动状态,该活动环境为所有生产流量提供服务。所有云平台均提供了用于实施蓝绿部署的选项。

NAME

参考资料

3.2 - 微服务灾难

2014 年 Martin Fowler 发表了一篇关于微服务的文章,当时,我所在的团队正在构建面向服务的架构。这篇文章以及随后的炒作几乎影响了世界上所有的软件团队。那时,“Netflix OSS 栈”是世界上最酷的东西,它可以让世界各地的工程师在分布式系统中使用 Netflix 的经验。六年多过去了,如果我们现在来看看软件工程的工作,就会发现,其中大部分都是关于微服务的架构的。

炒作驱动开发

在 2010 年代的早期,很多组织都面临着软件开发周期的挑战。与其他 50、100 或 200 名工程师一起工作的人,他们在开发环境、QA 过程和程序部署方面都很困难。Martin Fowler 的《Continuous Delivery》(译注:尚无中译本)一书给许多团队带来了曙光,他们开始意识到,他们那“雄伟”的单体应用正给他们带来组织问题。所以,微服务对软件工程师很有吸引力。在一个大项目中引入持续交付或部署,而不是一开始就引入,更具有挑战性。

于是,团队开始拆分三个、十个、一百个微服务。其中大部分都使用**“JSON over HTTP”**(其他人可能会称之为 RESTful)API 来在这些组件之间实现远程调用。人们对 HTTP 协议非常熟悉,这看起来是一种将单体应用转换成小程序块的简单方法。这时,团队在 15 分钟之内就开始将代码部署到生产环境中。再也没有“哦,团队 A 破坏了 CI 管道,我不能部署我的代码”这样的情况了,这种感觉棒极了!

但是,大多数工程师都忘了,在解决软件架构层面的组织问题的同时,他们也引入了许多复杂性。分布式系统的谬误变得越来越明显,并很快让这些团队感到头疼。甚至那些已经在做客户机 / 服务器架构的公司,当他们的系统中有超过 10 个移动部分时,也会出现这种情况。

现实的反击

做出重大的架构改变并非没有成本。团队开始认识到,共享数据库是一种单点故障。后来他们意识到,他们各自的领域创造了一个全新的世界:最终的一致性就是一件事。在你提取数据的服务失败后该怎么办?很多问题开始堆积如山。高速开发速度的承诺被寻找错误、事件和数据一致性问题等压得喘不过气。另外一个问题是,工程师需要一种集中的日志和可观察性解决方案,在几十个服务中发现并纠正这些缺陷。

灾难:服务规模过小

随着开发人员创造力的爆发,每天都能创造出新的服务。一项新功能?咣当,让我们开始服务吧!突然之间,20 名工程师组成了维护 50 项服务的小组。一人负责一项服务还不够!一般而言,代码的问题在于它会“腐烂”。维护每一项服务都是要付出代价的。想象一下,在你的服务团队中传播一个库的升级。再想象一下,这些服务开始于不同的时间点,具有不同的架构、业务逻辑和所使用的框架之间的纠葛。那是多么可怕啊!解决这些问题的方法当然是有的。其中大部分都不能使用,而其他一些则需要花费很多 FTE 工作。

另外一种感觉是,我被告知,在服务 A 中部署新功能,并且在服务 B 中同时部署,或者当人们开始编写服务以生成 CSV 时。为什么会有人引入网络跳转,以产生世界上已知的文件格式?这东西谁来维护?有些团队正在受服务之苦。更糟的是,它在开发过程中会产生许多摩擦。与仅仅在 IDE 中查看一个项目不同,人们需要一次打开多个项目才能了解所有这些混乱的情况。

灾难:开发环境

我已经记不清有多少次有人走近我说:

“嘿,João。你有时间吗?我们需要改善开发环境了!大家都在抱怨这些事,可是都没用!”

这一问题涉及各个层面。移动开发者不需要在开发环境中开发功能就可以实现,或者后端开发者想要尝试他们的服务而不会破坏任何业务流程。如果有人想在生产之前在移动应用中测试整个过程,这也是一个问题。

跨分布式系统的开发环境存在一些问题,尤其是规模方面:

  1. 在云供应商中启动 200 个服务需要花费多少钱?你能做到吗?你是否能够启动运行他们所需的基础设施呢?
  2. 这需要多长时间呢?加入一个移动工程师开始开发一项功能,在给定的版本中有一组服务,当这些服务完成之后,有 10 个新版本被部署到生产中,那会怎样?
  3. 测试数据怎么样?你是否拥有所有服务的测试数据?在整个 Fleet 中都保持一致,所以用户和其他实体相匹配?
  4. 当你开发一个多租户、多区域的应用时,如何配置和功能标志?怎样跟上生产进度?若同时更改缺省值呢?

这些只是冰山一角而已。你可能会考虑将工程技术应用于这个问题。那也许行得通。但是,我怀疑大多数组织是否有足够大的规模来完成这项工作。这样做既麻烦又费钱。

灾难:端到端测试

不难想象,端到端测试和开发环境有相似的问题。在此之前,使用虚拟机或容器创建新的开发环境相对简单。同样,使用 Selenium 创建测试套件非常简单,它可以在部署新版本之前通过业务流并判断它们是否在工作。有了微服务,即使我们能够解决以上关于构建环境的所有问题,我们也不能再次宣布系统正在运行。我们至多可以这样说,运行特定版本的服务和特定配置的系统可以在特定的时间点上正常工作。真是大不相同啊!

要让人们相信我们只能进行几次这样的测试是非常困难的。而且在持续集成(Continuous Integration)流程中运行这些测试并不够。它们应该持续运行。它们应该针对生产运行情况发出相应的警报。我已经分享了无数次 Cindy Sridharan 的文章《在生产中测试,安全的方法》(Testing in production, the safe way),试图让人们理解我的观点。

灾难:巨大的共享数据库

一种简单的方法就是继续使用共享数据库,这样就可以避免单体应用,同时保证数据的一致性。这种方法不会增加操作负荷,而且可以轻松地一步一步地切割单体应用。但它也有相当大的缺点。这不仅是一个明显的单点故障,而且违背了面向服务架构的一些原则。你是否为每项服务创建一个用户?你是否具有细粒度的权限,以便服务 A 只能读写特定的表?假如某人不小心删除了索引怎么办?怎样知道有多少服务使用了不同的表?那扩容呢?

解决这些问题本身就变成了一个全新的难题。在技术上,这可能不是一个无关紧要的小问题,因为数据库经常比软件寿命长。用数据复制来解决问题——不管是 Kafaka、AWS DMS 还是其他什么——都需要你的工程团队理解数据库的细节,以及如何处理重复时间等等。

灾难:API 网关

在面向服务的架构中,API 网关是一种典型模式。它们帮助解耦后端与前端消费者。在实施端点聚合、速率限制或跨系统认证方面,它们也有用。近来,业界倾向于 backend-for-frontend(BFF,服务于前端的后端)的架构,将网关部署到前端的每个消费者群体(iOS、Android、Web 或桌面应用),从而解耦它们的进化。

和世界上的任何东西一样,人们开始有了新的创造性用例。有时这只是一个小技巧,使移动应用能够向后兼容。突然间,你的 API 网关变成了一个单点故障,因为人们发现在一个地方进行认证更加容易,其中还包含一些出乎意料的业务逻辑。现在,你不再有一个获得所有流量的单体应用,而是有一个自己开发的 Spring Boot 服务来或许所有的流量!会出什么问题呢?工程人员很快意识到这是个错误,但是由于存在大量的定制,有时候他们不能用它来取代无状态的、可扩展的定制。

当使用未分页的端点或返回大量响应时,就会导致 API 网关灾难。又或者,如果你在没有后备机制的情况下进行聚合,仅仅调用一次 API 就会“烧毁”你的网关。

灾难:超时、重试、弹性

分布式系统经常处于局部故障模式。如果服务 A 不能与服务 B 取得联系,会发生什么?咱们可以再试一次,对吗?但是这很快让我们陷入了困惑之中。我见过有些团队使用断路器,然后对下游服务进行 HTTP 调用时会超时。尽管这可能是一种正常的反应,为我们争取了一些时间来解决问题,但是它会产生二阶效应。所有这些请求都将被断路器取消,因为它们太长,在断路器上的时间太长。随着流量的增加,会有越来越多的请求进入队列,结果会比你希望修复的更糟。工程师们都在努力理解队列理论,理解为什么会出现超时现象。同样的事情发生在团队开始讨论 HTTP 客户端的线程池等问题时。尽管对这些东西进行配置本身就是一种艺术,但基于直觉来设置数值会使你陷入严重的停机状态。

在从失败中恢复的过程中,一个棘手的问题是并非所有的失败都一样。有些情况下,我们会希望我们的消费者是等幂的。但是这意味着我们应该积极的决定每一种失败情况下该怎么做。消费者是否等幂?能否重试这个调用?在认识到存在巨大的数据完整性问题之前,我已经看到许多工程师忽视了这些,因为它们只是“边缘情况”。

即使你设置了后备机制,重试也比所有这些更加复杂。假设你的移动应用有 500 万用户,而更新用户首选项的消息总线暂时无法运行。你创建了一个支持这种情况的后备机制,该机制通过 HTTP API 调用用户的首选项服务。你应该知道我在说什么吧。如今,该服务突然出现了巨大的流量尖峰,可能无法应付所有的流量。更糟糕的是:你的服务可能接收到所有这些新请求,但是如果重试机制不能实现指数退避和抖动,那么你就可能遇到来自移动应用的分布式拒绝服务。

看到所有这些灾难,你还喜欢分布式系统吗?

要是我告诉你,我只是写下了我所看到的灾难中的一小部分呢?分布式系统很难掌握,而且大多数软件工程师只是在最近才持续接触到它们。

好消息是,对于我所说的很多灾难,我们都能找到解决方案,行业已经创造除了更好的工具,使得除了美国五大科技巨头(Facebook、苹果、亚马逊、Netflix、谷歌)之外的其他组织都能解决这些问题。

我还是喜欢分布式系统,而且我还是觉得微服务是一个解决组织问题的好方法。但是,当我们把失败看作“边缘案例”或者我们认为不可能发生的事时,问题就出现了。在一定范围内,这些边缘案例成为新常态,我们应该加以应对。

原文链接:https://world.hey.com/joaoqalves/disasters-i-ve-seen-in-a-microservices-world-a9137a51

3.3 - 故障容错模式

软件故障的容错方法如果用一句话来简单概况的话也简单:通过定义规则来容忍系统缺陷。但这样的定义未免过于大而空,我们需要切实有效可落地的方式。下面介绍9种常用的处理方式。

容错、熔断、隔离?

  • “隔离”是一种异常检测机制,常用的检测方法是请求超时、流量过大等。一般的设置参数包括超时时间、同时并发请求个数等。

  • “熔断”是一种异常反应机制,“熔断”依赖于“隔离”。熔断通常基于错误率来实现。一般的设置参数包括统计请求的个数、错误率等。

  • “容错”是一种异常处理机制,“容错”依赖于“熔断”。熔断以后,会调用“容错”的方法。一般的设置参数包括调用容错方法的次数等。

Process Pairs

也就是最简单的backup方案,保证系统在某一个时刻总能有一个进程来处理客户的输入请求,能处理短暂的软件错误。

Graceful Degradation

就是我们常说的降级,在系统遭遇某个错误之后不提供完整功能,只给用户开放部分基础能力,此解决方案通常是上面的backup方案持续性不work的时候采取的保护措施。

Selective Retry

选择性重试也是可选的方案之一,它主要适用于是突发式高负载资源短缺的场景,例如,网络瞬时打满峰值不可访问或者内存资源短缺,重试能够增加资源分配成功的可能性。

State Handling

在系统不能提供服务后,又要保证client的无状态属性。服务端需要持续保存当前的状态,用于故障后的重试。

Linking Process

有些程序进程是相互依赖的,如果某个进程出错,其他依赖的进程需要侦测到错误,明确做相应的处理,通常是结束全部依赖进程。

Checkpoint

周期性的保存进程的状态。如果需要保证数据正确,回滚到最近保存的状态即可,只是会有部分的数据丢失。

Update Lost

上面方案的补充版,在两个checkpoint之间系统故障,需要保存客户请求,在rollback前一个版本之后重新处理这些请求。

Process Pools

使用资源预分配技术,按照经验设定好某些请求资源的需求量,为程序分配合适的资源。就像我们为某个任务分配线程池大小一样。

Micro reboot

通过解耦系统组件,使得系统在遭遇故障时,只需要重启需要的组件,而不必重启整个系统。核心是组件和数据分离,数据的处理通过持久化存储的方式保证一致。

4 - 高并发系统

原文链接:高并发系统设计

4.1 - CH01-基础-设计方法

我们知道,高并发代表着大流量,高并发系统设计的魅力就在于我们能够凭借自己的聪明才智设计巧妙的方案,从而抵抗巨大流量的冲击,带给用户更好的使用体验。这些方案好似能操纵流量,让流量更加平稳得被系统中的服务和组件处理。

来做个简单的比喻吧。

从古至今,长江和黄河流域水患不断,远古时期:

  • 大禹曾拓宽河道,清除淤沙让流水更加顺畅
  • 都江堰作为史上最成功的的治水案例之一,用引流将岷江之水分流到多个支流中,以分担水流压力
  • 三门峡和葛洲坝通过建造水库将水引入水库先存储起来,然后再想办法把水库中的水缓缓地排出去,以此提高下游的抗洪能力。

而我们在应对高并发大流量时也会采用类似 抵御洪水 的方案,归纳起来共有三种方法:

  • Scale-out(横向扩展)

    分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。

  • 缓存

    使用缓存来提高系统的性能,就好比用 「拓宽河道」的方式抵抗高并发大流量的冲击。

  • 异步

    在某些场景下,未处理完成之前,我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求

简单介绍了这三种方法之后,我再详细地带你了解一下,这样当你在设计高并发系统时就可以有考虑的方向了。当然了,这三种方法会细化出更多的内容,我会在后面的课程中深入讲解。

首先,我们先来了解第一种方法:Scale-out。

Scale-up vs Scale-out

著名的「摩尔定律」是由 Intel 的创始人之一戈登·摩尔于 1965 年提出的。这个定律提到,集成电路上可容纳的晶体管的数量约每隔两年会增加一倍

后来,Intel 首席执行官大卫·豪斯提出「18 个月」的说法,即预计 18 个月会将芯片的性能提升一倍,这个说法广为流传。

摩尔定律虽然描述的是芯片的发展速度,但我们可以延伸为整体的硬件性能,从 20 世纪后半叶开始,计算机硬件的性能是指数级演进的。

直到现在,摩尔定律依然生效,在半个世纪以来的 CPU 发展过程中,芯片厂商靠着在有限面积上做更小的晶体管的黑科技,大幅度地提升着芯片的性能。从第一代集成电路上只有十几个晶体管,到现在一个芯片上动辄几十亿晶体管的数量,摩尔定律指引着芯片厂商完成了技术上的飞跃。

但是有专家预测,摩尔定律可能在未来几年之内不再生效,原因是目前的芯片技术已经做到了 10nm 级别,在工艺上已经接近极限,再往上做,即使有新的技术突破,在成本上也难以被市场接受。后来,双核和多核技术的产生拯救了摩尔定律,这些技术的思路是将多个 CPU 核心压在一个芯片上,从而大大提升 CPU 的并行处理能力

我们在高并发系统设计上也沿用了同样的思路:

  • 将类似追逐摩尔定律不断提升 CPU 性能的方案叫做 Scale-up(纵向扩展)

    容纳更多的晶体管

  • 把类似 CPU 多核心的方案叫做 Scale-out

    单核心变多核心

这两种思路在实现方式上是完全不同的。

  • Scale-up

    通过购买性能更好的硬件来提升系统的并发处理能力,比方说目前系统 4 核 4G 每秒可以处理 200 次请求,那么如果要处理 400 次请求呢?很简单,我们把机器的硬件提升到 8 核 8G(硬件资源的提升可能不是线性的,这里仅为参考)。

  • Scale-out

    则是另外一个思路,它通过将多个低性能的机器组成一个分布式集群来共同抵御高并发流量的冲击。沿用刚刚的例子,我们可以使用两台 4 核 4G 的机器来处理那 400 次请求。

**那么什么时候选择 Scale-up,什么时候选择 Scale-out 呢?**一般来讲,在我们系统设计初期会考虑使用 Scale-up 的方式,因为这种方案足够简单,所谓能用堆砌硬件解决的问题就用硬件来解决,但是当系统并发超过了单机的极限时,我们就要使用 Scale-out 的方式。

Scale-out 虽然能够突破单机的限制,但也会引入一些复杂问题。比如,如果某个节点出现故障如何保证整体可用性?当多个节点有状态需要同步时,如何保证状态信息在不同节点的一致性?如何做到使用方无感知的增加和删除节点?等等。其中每一个问题都涉及很多的知识点,我会在后面的课程中深入地讲解,这里暂时不展开了。

说完了 Scale-out,我们再来看看高并发系统设计的另一种方法:缓存。

使用缓存提升性能

Web 2.0 是缓存的时代,这一点毋庸置疑。缓存遍布在系统设计的每个角落,从操作系统到浏览器,从数据库到消息队列,任何略微复杂的服务和组件中,你都可以看到缓存的影子。我们使用缓存的主要作用是提升系统的访问性能,那么在高并发的场景下,就可以支撑更多用户的同时访问。

那么为什么缓存可以大幅度提升系统的性能呢?我们知道数据是放在持久化存储中的,一般的持久化存储都是使用磁盘作为存储介质的,而普通磁盘数据由机械手臂、磁头、转轴、盘片组成,盘片又分为磁道、柱面和扇区,盘片构造图我放在下面了。

NAME

盘片是存储介质,每个盘片被划分为多个同心圆,信息都被存储在同心圆之中,这些 同心圆就是磁道。在磁盘工作时盘片是在高速旋转的,机械手臂驱动磁头沿着径向移动,在磁道上读取所需要的数据。我们把 磁头寻找信息花费的时间叫做寻道时间

普通磁盘的寻道时间是 10ms 左右,而相比于磁盘寻道花费的时间,CPU 执行指令和内存寻址的时间都在是 ns(纳秒)级别,从千兆网卡上读取数据的时间是在 μs(微秒)级别。所以在整个计算机体系中,磁盘是最慢的一环,甚至比其它的组件要慢几个数量级。因此,我们通常使用以内存作为存储介质的缓存,以此提升性能。

当然,缓存的语义已经丰富了很多,我们 可以将任何降低响应时间的中间存储都称为缓存。缓存的思想遍布很多设计领域,比如在操作系统中 CPU 有多级缓存,文件有 Page Cache 缓存,你应该有所了解。

异步处理

异步 也是一种常见的高并发设计方法,我们在很多文章和演讲中都能听到这个名词,与之共同出现的还有它的反义词:同步。比如,分布式服务框架 Dubbo 中有同步方法调用和异步方法调用,IO 模型中有同步 IO 和异步 IO。

那么什么是同步,什么是异步呢? 以方法调用为例,同步调用代表调用方要阻塞等待被调用方法中的逻辑执行完成。这种方式下,当被调用方法响应时间较长时,会造成调用方长久的阻塞,在高并发下会造成整体系统性能下降甚至发生雪崩。

异步调用恰恰相反,调用方不需要等待方法逻辑执行完成就可以返回执行其他的逻辑,在被调用方法执行完毕后再通过回调、事件通知等方式将结果反馈给调用方。

异步调用在大规模高并发系统中被大量使用,比如我们熟知的 12306 网站。 当我们订票时,页面会显示系统正在排队,这个提示就代表着系统在异步处理我们的订票请求。在 12306 系统中查询余票、下单和更改余票状态都是比较耗时的操作,可能涉及多个内部系统的互相调用,如果是同步调用就会像 12306 刚刚上线时那样,高峰期永远不可能下单成功。

而采用异步的方式,后端处理时会把请求丢到消息队列中,同时快速响应用户,告诉用户我们正在排队处理,然后释放出资源来处理更多的请求。订票请求处理完之后,再通知用户订票成功或者失败。

处理逻辑后移到异步处理程序中,Web 服务的压力小了,资源占用的少了,自然就能接收更多的用户订票请求,系统承受高并发的能力也就提升了。

NAME

既然我们了解了这三种方法,那么是不是意味着在高并发系统设计中,开发一个系统时要把这些方法都用上呢?当然不是,系统的设计是不断演进的

罗马不是一天建成的,系统的设计也是如此。 不同量级的系统有不同的痛点,也就有不同的架构设计的侧重点。如果都按照百万、千万并发来设计系统,电商一律向淘宝看齐,IM 全都学习微信和 QQ,那么这些系统的命运一定是灭亡。

因为淘宝、微信的系统虽然能够解决同时百万、千万人同时在线的需求,但其内部的复杂程度也远非我们能够想象的。盲目地追从只能让我们的架构复杂不堪,最终难以维护。就拿从单体架构往服务化演进来说,淘宝也是在经历了多年的发展后,发现系统整体的扩展能力出现问题时,开始启动服务化改造项目的。

我之前也踩过一些坑, 参与的一个创业项目在初始阶段就采用了服务化的架构,但由于当时人力有限,团队技术积累不足,因此在实际项目开发过程中,发现无法驾驭如此复杂的架构,也出现了问题难以定位、系统整体性能下降等多方面的问题,甚至连系统宕机了都很难追查到根本原因,最后不得不把服务做整合,回归到简单的单体架构中。

所以我建议一般系统的演进过程应该遵循下面的思路:

  • 最简单的系统设计满足业务需求和流量现状,选择最熟悉的技术体系。
  • 随着流量的增加和业务的变化,修正架构中存在问题的点,如单点问题,横向扩展问题,性能无法满足需求的组件。在这个过程中,选择社区成熟的、团队熟悉的组件帮助我们解决问题,在社区没有合适解决方案的前提下才会自己造轮子。
  • 当对架构的小修小补无法满足需求时,考虑重构、重写等大的调整方式以解决现有的问题。

以淘宝为例, 当时在业务从 0 到 1 的阶段是通过购买的方式快速搭建了系统。而后,随着流量的增长,淘宝做了一系列的技术改造来提升高并发处理能力,比如数据库存储引擎从 MyISAM 迁移到 InnoDB,数据库做分库分表,增加缓存,启动中间件研发等。

当这些都无法满足时就考虑对整体架构做大规模重构,比如说著名的「五彩石」项目让淘宝的架构从单体演进为服务化架构。正是通过逐步的技术演进,淘宝才进化出如今承担过亿 QPS 的技术架构。

归根结底一句话:高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。

课程小结

在今天的课程中,我带着你了解了高并发系统设计的三种通用方法:Scale-out、缓存和异步。 这三种方法可以在做方案设计时灵活地运用,但它不是具体实施的方案,而是三种思想,在实际运用中会千变万化。

就拿 Scale-out 来说,数据库一主多从、分库分表、存储分片都是它的实际应用方案。而我们需要注意的是,在应对高并发大流量的时候,系统是可以通过增加机器来承担流量冲击的,至于要采用什么样的方案还是要具体问题具体分析。

4.2 - CH02-基础-架构分层

在系统从 0 到 1 的阶段,为了让系统快速上线,我们通常是不考虑分层的。但是随着业务越来越复杂,大量的代码纠缠在一起,会出现逻辑不清晰、各模块相互依赖、代码扩展性差、改动一处就牵一发而动全身等问题

这时,对系统进行分层就会被提上日程,那么我们要如何对架构进行分层?架构分层和高并发架构设计又有什么关系呢?本节课,我将带你寻找答案。

什么是分层架构

软件架构分层在软件工程中是一种常见的设计方式,它是将整体系统拆分成 N 个层次,每个层次有独立的职责,多个层次协同提供完整的功能。

我们在刚刚成为程序员的时候,会被「教育」说系统的设计要是「MVC」(Model-View-Controller)架构。它将整体的系统分成了 Model(模型),View(视图)和 Controller(控制器)三个层次,也就是将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了 表现和逻辑的解耦,是一种标准的软件分层架构。

NAME

另外一种常见的分层方式是将整体架构分为表现层、逻辑层和数据访问层:

  • 表现层,顾名思义嘛,就是展示数据结果和接受用户指令的,是最靠近用户的一层;
  • 逻辑层里面有复杂业务的具体实现;
  • 数据访问层则是主要处理和存储之间的交互。

这是在架构上最简单的一种分层方式。其实,我们在不经意间已经按照三层架构来做系统分层设计了,比如在构建项目的时候,我们通常会建立三个目录:Web、Service 和 Dao,它们分别对应了表现层、逻辑层还有数据访问层。

NAME

除此之外,如果我们稍加留意,就可以发现很多的分层的例子。比如我们在大学中学到的 OSI 网络模型,它把整个网络分了七层,自下而上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

NAME

工作中经常能用到 TCP/IP 协议,它把网络简化成了四层(如上图右侧),即链路层、网络层、传输层和应用层。每一层各司其职又互相帮助,网络层负责端到端的寻址和建立连接,传输层负责端到端的数据传输等,同时呢相邻两层还会有数据的交互。这样可以 隔离关注点,让不同的层专注做不同的事情

Linux 文件系统也是分层设计的,从下图你可以清晰地看出文件系统的层次。

  • 在文件系统的最上层是 虚拟文件系统(VFS),用来屏蔽不同的文件系统之间的差异,提供统一的系统调用接口。
  • 虚拟文件系统的下层是 Ext3、Ext4 等各种文件系统
  • 再向下是为了屏蔽不同硬件设备的实现细节,我们抽象出来的单独的一层——通用块设备层,
  • 然后就是不同类型的磁盘了。
NAME

我们可以看到,某些层次负责的是对下层不同实现的抽象,从而对上层屏蔽实现细节。比方说 VFS 对上层(系统调用层)来说提供了统一的调用接口,同时对下层中不同的文件系统规约了实现模型,当新增一种文件系统实现的时候,只需要按照这种模型来设计,就可以无缝插入到 Linux 文件系统中。

那么,为什么这么多系统一定要做分层的设计呢?答案是分层设计存在一定的优势。

分层有什么好处

**分层的设计可以简化系统设计,让不同的人专注做某一层次的事情。**想象一下,如果你要设计一款网络程序却没有分层,该是一件多么痛苦的事情。

因为你必须是一个通晓网络的全才,要知道各种网络设备的接口是什么样的,以便可以将数据包发送给它。你还要关注数据传输的细节,并且需要处理类似网络拥塞,数据超时重传这样的复杂问题。当然了,你更需要关注数据如何在网络上安全传输,不会被别人窥探和篡改。

而有了分层的设计,你只需要专注设计应用层的程序就可以了,其他的,都可以交给下面几层来完成。

**再有,分层之后可以做到很高的复用。**比如,我们在设计系统 A 的时候,发现某一层具有一定的通用性,那么我们可以把它抽取独立出来,在设计系统 B 的时候使用起来,这样可以减少研发周期,提升研发的效率。

**最后一点,分层架构可以让我们更容易做横向扩展。**如果系统没有分层,当流量增加时我们需要针对整体系统来做扩展。但是,如果我们按照上面提到的三层架构将系统分层后,那么我们就可以针对具体的问题来做细致的扩展。

比如说,业务逻辑里面包含有比较复杂的计算,导致 CPU 成为性能的瓶颈,那这样就可以把逻辑层单独抽取出来独立部署,然后只对逻辑层来做扩展,这相比于针对整体系统扩展所付出的代价就要小的多了。

这一点也可以解释我们课程开始时提出的问题:架构分层究竟和高并发设计的关系是怎样的?在第 1 节中中我们了解到,横向扩展是高并发系统设计的常用方法之一,既然分层的架构可以为横向扩展提供便捷, 那么支撑高并发的系统一定是分层的系统。

如何来做系统分层

说了这么多分层的优点,那么当我们要做分层设计的时候,需要考虑哪些关键因素呢?

在我看来,最主要的一点就是你需要理清楚 每个层次的边界是什么。你也许会问:如果按照三层架构来分层的话,每一层的边界不是很容易就界定吗?

没错,当业务逻辑简单时,层次之间的边界的确清晰,开发新的功能时也知道哪些代码要往哪儿写。但是当业务逻辑变得越来越复杂时,边界就会变得越来越模糊,给你举个例子。

任何一个系统中都有用户系统,最基本的接口是返回用户信息的接口,它调用逻辑层的 GetUser 方法,GetUser 方法又和 User DB 交互获取数据,就像下图左边展示的样子。

这时,产品提出一个需求,在 APP 中展示用户信息的时候,如果用户不存在,那么要自动给用户创建一个用户。同时,要做一个 HTML5 的页面,HTML5 页面要保留之前的逻辑,也就是不需要创建用户。这时逻辑层的边界就变得不清晰,表现层也承担了一部分的业务逻辑(将获取用户和创建用户接口编排起来)

NAME

那我们要如何做呢?我们可以将原先的三层架构细化成下面的样子:

NAME

我来解释一下这个分层架构中的每一层的作用:

  • 终端显示层:

    各端模板渲染并执行显示的层。当前主要是 Velocity 渲染,JS 渲染, JSP 渲染,移动端展示等。

  • 开放接口层:

    将 Service 层方法封装成开放接口,同时进行网关安全控制和流量控制等。

  • Web 层:

    主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。

  • Service 层:业务逻辑层。

  • Manager 层:

    通用业务处理层。这一层主要有两个作用:

    • 其一,你可以将原先 Service 层的一些通用能力下沉到这一层,比如 与缓存和存储交互策略,中间件的接入;
    • 其二,你也可以在这一层 封装对第三方接口的调用,比如调用支付服务,调用审核服务等
  • DAO 层:

    数据访问层,与底层 MySQL、Oracle、Hbase 等进行数据交互。

  • 外部接口或第三方平台:

    包括其它部门 RPC 开放接口,基础平台,其它公司的 HTTP 接口。

在这个分层架构中 主要增加了 Manager 层,它与 Service 层的关系是:Manager 层提供原子的服务接口,Service 层负责依据业务逻辑来编排原子接口

以上面的例子来说,Manager 层提供 创建用户获取用户信息 的接口,而 Service 层负责将这两个接口组装起来。这样就把原先散布在表现层的业务逻辑都统一到了 Service 层,每一层的边界就非常清晰了。

除此之外,分层架构需要考虑的另一个因素,是 层次之间一定是相邻层互相依赖数据的流转也只能在相邻的两层之间流转

我们还是以三层架构为例,数据从表示层进入之后一定要流转到逻辑层,做业务逻辑处理,然后流转到数据访问层来和数据库交互。那么你可能会问:如果业务逻辑很简单的话可不可以从表示层直接到数据访问层,甚至直接读数据库呢?

其实从功能上是可以的,但是从长远的架构设计考虑,这样会造成层级调用的混乱,比方说如果表示层或者业务层可以直接操作数据库,那么一旦数据库地址发生变更,你就需要在多个层次做更改,这样就失去了分层的意义,并且对于后面的维护或者重构都会是灾难性的。

分层架构的不足

任何事物都不可能是尽善尽美的,分层架构虽有优势也会有缺陷,它最主要的一个缺陷就是增加了代码的复杂度

这是显而易见的嘛,明明可以在接收到请求后就可以直接查询数据库获得结果,却偏偏要在中间插入多个层次,并且有可能每个层次只是简单地做数据的传递。有时增加一个小小的需求也需要更改所有层次上的代码,看起来增加了开发的成本,并且从调试上来看也增加了复杂度,原本如果直接访问数据库我只需要调试一个方法,现在我却要调试多个层次的多个方法。

另外一个可能的缺陷是,如果我们把每个层次独立部署,层次间通过网络来交互,那么多层的架构在性能上会有损耗。这也是为什么服务化架构性能要比单体架构略差的原因,也就是所谓的 多一跳 问题。

那我们是否要选择分层的架构呢?答案当然是肯定的。

你要知道,任何的方案架构都是有优势有缺陷的,天地尚且不全何况我们的架构呢?分层架构固然会增加系统复杂度,也可能会有性能的损耗,但是相比于它能带给我们的好处来说,这些都是可以接受的,或者可以通过其它的方案解决的。我们在做决策的时候切不可以偏概全,因噎废食。

课程小结

今天我带着你了解了分层架构的优势和不足,以及我们在实际工作中如何来对架构做分层。我想让你了解的是,分层架构是软件设计思想的外在体现,是一种实现方式。我们熟知的一些软件设计原则都在分层架构中有所体现。

比方说:

  • 单一职责原则 规定每个类只有单一的功能

    在这里可以引申为每一层拥有单一职责,且层与层之间边界清晰;

  • 迪米特法则 原意是一个对象应当对其它对象有尽可能少的了解

    在分层架构的体现是数据的交互不能跨层,只能在相邻层之间进行;

  • 开闭原则 要求软件对扩展开放,对修改关闭。

    它的含义其实就是将抽象层和实现层分离,抽象层是对实现层共有特征的归纳总结,不可以修改,但是具体的实现是可以无限扩展,随意替换的。

掌握这些设计思想会自然而然地明白分层架构设计的妙处,同时也能帮助我们做出更好的设计方案。

4.3 - CH03-基础-高性能

提到互联网系统设计,你可能听到最多的词儿就是 三高,也就是 高并发高性能高可用,它们是互联网系统架构设计永恒的主题。在前两节课中,我带你了解了高并发系统设计的含义,意义以及分层设计原则,接下来,我想带你整体了解一下高并发系统设计的目标,然后在此基础上,进入我们今天的话题:如何提升系统的性能?

高并发系统设计的三大目标:高性能、高可用、可扩展

高并发, 是指运用设计手段让系统能够处理更多的用户并发请求,也就是承担更大的流量。它是一切架构设计的背景和前提,脱离了它去谈性能和可用性是没有意义的。很显然嘛,你在每秒一次请求和每秒一万次请求,两种不同的场景下,分别做到毫秒级响应时间和五个九(99.999%)的可用性,无论是设计难度还是方案的复杂度,都不是一个级别的。

而性能和可用性, 是我们实现高并发系统设计必须考虑的因素。

性能反应了系统的使用体验,想象一下,同样承担每秒一万次请求的两个系统,一个响应时间是毫秒级,一个响应时间在秒级别,它们带给用户的体验肯定是不同的。

可用性则表示系统可以正常服务用户的时间。我们再类比一下,还是两个承担每秒一万次的系统,一个可以做到全年不停机、无故障,一个隔三差五宕机维护,如果你是用户,你会选择使用哪一个系统呢?答案不言而喻。

另一个耳熟能详的名词叫 可扩展性 ,它同样是高并发系统设计需要考虑的因素。为什么呢?我来举一个具体的例子。

流量分为 平时流量峰值流量 两种,峰值流量可能会是平时流量的几倍甚至几十倍,在应对峰值流量的时候,我们通常需要在架构和方案上做更多的准备。这就是淘宝会花费大半年的时间准备双十一,也是在面对「明星离婚」等热点事件时,看起来无懈可击的微博系统还是会出现服务不可用的原因。 而易于扩展的系统能在短时间内迅速完成扩容,更加平稳地承担峰值流量。

高性能、高可用和可扩展,是我们在做高并发系统设计时追求的三个目标,我会用三节课的时间,带你了解在高并发大流量下如何设计高性能、高可用和易于扩展的系统。

了解完这些内容之后,我们正式进入今天的话题:如何提升系统的性能?

性能优化原则)性能优化原则

「天下武功,唯快不破」。性能是系统设计成功与否的关键,实现高性能也是对程序员个人能力的挑战。不过在了解实现高性能的方法之前,我们先明确一下性能优化的原则。

  • 首先,性能优化一定不能盲目,一定是问题导向的

    脱离了问题,盲目地提早优化会增加系统的复杂度,浪费开发人员的时间,也因为某些优化可能会对业务上有些折中的考虑,所以也会损伤业务。

  • 其次,性能优化也遵循「八二原则」

    即你可以用 20% 的精力解决 80% 的性能问题。所以我们在优化过程中一定要抓住主要矛盾,优先优化主要的性能瓶颈点。

  • 再次,性能优化也要有数据支撑

    在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。

  • 最后,性能优化的过程是持续的

    高并发的系统通常是业务逻辑相对复杂的系统,那么在这类系统中出现的性能问题通常也会有多方面的原因。因此,我们在做性能优化的时候要明确目标,比方说,支撑每秒 1 万次请求的吞吐量下响应时间在 10ms,那么我们就需要持续不断地寻找性能瓶颈,制定优化方案,直到达到目标为止。

在以上四个原则的指引下,掌握常见性能问题的排查方式和优化手段,就一定能让你在设计高并发系统时更加游刃有余。

性能的度量指标)性能的度量指标

性能优化的第三点原则中提到,对于性能我们需要有度量的标准,有了数据才能明确目前存在的性能问题,也能够用数据来评估性能优化的效果。 所以明确性能的度量指标十分重要。

一般来说,度量性能的指标是 系统接口的响应时间,但是单次的响应时间是没有意义的,你需要知道一段时间的性能情况是什么样的。所以,我们需要收集这段时间的响应时间数据,然后依据一些统计方法计算出 特征值,这些特征值就能够代表这段时间的性能情况。我们常见的特征值有以下几类。

  • 平均值

    顾名思义,平均值是把这段时间所有请求的响应时间数据相加,再除以总请求数。平均值可以在一定程度上反应这段时间的性能,但它敏感度比较差,如果这段时间有少量慢请求时,在平均值上并不能如实的反应。

    举个例子,假设我们在 30s 内有 10000 次请求,每次请求的响应时间都是 1ms,那么这段时间响应时间平均值也是 1ms。这时,当其中 100 次请求的响应时间变成了 100ms,那么整体的响应时间是 100 * 100 + 9900 * 1) / 10000 = 1.99ms。你看,虽然从平均值上来看仅仅增加了不到 1ms,但是实际情况是有 1% 的请求(100/10000) 的响应时间已经增加了 100 倍。 所以,平均值对于度量性能来说只能作为一个参考。

  • 最大值

    这个更好理解,就是这段时间内所有请求响应时间最长的值,但它的问题又在于过于敏感了

    还拿上面的例子来说,如果 10000 次请求中只有一次请求的响应时间达到 100ms,那么这段时间请求的响应耗时的最大值就是 100ms,性能损耗为原先的百分之一,这种说法明显是不准确的。

  • 分位值

    分位值有很多种,比如 90 分位、95 分位、75 分位。以 90 分位为例,我们把这段时间请求的 响应时间从小到大排序,假如一共有 100 个请求,那么排在第 90 位的响应时间就是 90 分位值。分位值排除了偶发极慢请求对于数据的影响,能够很好地反应这段时间的性能情况,分位值越大,对于慢请求的影响就越敏感

NAME

在我来看,分位值是最适合作为时间段内,响应时间统计值来使用的,在实际工作中也应用最多。除此之外,平均值也可以作为一个参考值来使用。

我在上面提到,脱离了并发来谈性能是没有意义的,我们通常使用 吞吐量 或者 同时在线用户数 来度量并发和流量,使用吞吐量的情况会更多一些。但是你要知道,这两个指标是呈倒数关系的。

这很好理解,响应时间 1s 时,吞吐量是每秒 1 次,响应时间缩短到 10ms,那么吞吐量就上升到每秒 100 次。所以,一般我们度量性能时都会同时兼顾吞吐量和响应时间,比如我们设立性能优化的目标时通常会这样表述:在每秒 1 万次的请求量下,响应时间 99 分位值在 10ms 以下。

那么,响应时间究竟控制在多长时间比较合适呢?这个不能一概而论。

从用户使用体验的角度来看:

  • 200ms 是第一个分界点:

    接口的响应时间在 200ms 之内,用户是感觉不到延迟的,就像是瞬时发生的一样。

  • 而 1s 是另外一个分界点:

    接口的响应时间在 1s 之内时,虽然用户可以感受到一些延迟,但却是可以接受的

  • 超过 1s 之后用户就会有明显等待的感觉,等待时间越长,用户的使用体验就越差。

所以,健康系统的 99 分位值的响应时间通常需要控制在 200ms 之内,而不超过 1s 的请求占比要在 99.99% 以上。

现在你了解了性能的度量指标,那我们再来看一看,随着并发的增长我们实现高性能的思路是怎样的。

高并发下的性能优化)高并发下的性能优化

假如说,你现在有一个系统,这个系统中处理核心只有一个,执行的任务的响应时间都在 10ms,它的吞吐量是在每秒 100 次。那么我们如何来优化性能从而提高系统的并发能力呢?主要有两种思路:

  • 一种是提高系统的处理核心数
  • 另一种是减少单次任务的响应时间。

1. 提高系统的处理核心数

提高系统的处理核心数就是 增加系统的并行处理能力,这个思路是优化性能最简单的途径。拿上一个例子来说,你可以把系统的处理核心数增加为两个,并且增加一个进程,让这两个进程跑在不同的核心上。这样从理论上,你系统的吞吐量可以增加一倍。当然了,在这种情况下,吞吐量和响应时间就不是倒数关系了,而是:吞吐量 = 并发进程数 / 响应时间

计算机领域的阿姆达尔定律(Amdahl’s law)是吉恩·阿姆达尔在 1967 年提出的。它描述了并发进程数与响应时间之间的关系,含义是在固定负载下,并行计算的加速比,也就是并行化之后效率提升情况,可以用下面公式来表示:(Ws + Wp) / (Ws + Wp/s)

其中,Ws 表示任务中的串行计算量,Wp 表示任务中的并行计算量,s 表示并行进程数。从这个公式我们可以推导出另外一个公式:1/(1-p+p/s)

其中,s 还是表示并行进程数,p 表示任务中并行部分的占比。当 p 为 1 时,也就是完全并行时,加速比与并行进程数相等;当 p 为 0 时,即完全串行时,加速比为 1,也就是说完全无加速;当 s 趋近于无穷大的时候,加速比就等于 1/(1-p),你可以看到它完全和 p 成正比。特别是,当 p 为 1 时,加速比趋近于无穷大。

以上公式的推导过程有些复杂,你只需要记住结论就好了。

我们似乎找到了解决问题的银弹,**是不是无限制地增加处理核心数就能无限制地提升性能,从而提升系统处理高并发的能力呢?**很遗憾,随着并发进程数的增加,并行的任务对于系统资源的争抢也会愈发严重。在某一个临界点上继续增加并发进程数,反而会造成系统性能的下降,这就是性能测试中的 拐点模型

NAME

从图中你可以发现:

  • 并发用户数处于轻压力区时,响应时间平稳,吞吐量和并发用户数线性相关。
  • 而当并发用户数处于重压力区时,系统资源利用率到达极限,吞吐量开始有下降的趋势,响应时间也会略有上升。
  • 这个时候,再对系统增加压力,系统就进入拐点区,处于超负荷状态,吞吐量下降,响应时间大幅度上升。

所以我们在评估系统性能时通常需要做压力测试,目的就是找到系统的「拐点」,从而知道系统的承载能力,也便于找到系统的瓶颈,持续优化系统性能。

说完了提升并行能力,我们再看看优化性能的另一种方式:减少单次任务响应时间。

2. 减少单次任务响应时间

想要减少任务的响应时间,首先要看你的系统是 CPU 密集型 还是 IO 密集型 的,因为不同类型的系统性能优化方式不尽相同。

CPU 密集型系统

CPU 密集型系统中,需要处理大量的 CPU 运算,那么选用更高效的算法或者减少运算次数就是这类系统重要的优化手段。比方说,如果系统的主要任务是计算 Hash 值,那么这时选用更高性能的 Hash 算法就可以大大提升系统的性能。发现这类问题的主要方式,是通过一些 Profile 工具来找到消耗 CPU 时间最多的方法或者模块,比如 Linux 的 perf、eBPF 等。

IO 密集型系统

IO 密集型系统指的是系统的大部分操作是在等待 IO 完成,这里 IO 指的是磁盘 IO 和网络 IO。我们熟知的系统大部分都属于 IO 密集型,比如数据库系统、缓存系统、Web 系统。这类系统的性能瓶颈可能出在系统内部,也可能是依赖的其他系统,而发现这类性能瓶颈的手段主要有两类。

  • 第一类是 采用工具

    Linux 的工具集很丰富,完全可以满足你的优化需要,比如网络协议栈、网卡、磁盘、文件系统、内存,等等。这些工具的用法很多,你可以在排查问题的过程中逐渐积累。除此之外呢,一些开发语言还有针对语言特性的分析工具,比如说 Java 语言就有其专属的内存分析工具。

  • 另外一类手段就是可以通过 监控 来发现性能问题。

    在监控中我们可以对任务的每一个步骤做分时的统计,从而找到任务的哪一步消耗了更多的时间。这一部分在演进篇中会有专门的介绍,这里就不再展开了。

那么找到了系统的瓶颈点,我们要如何优化呢?优化方案会随着问题的不同而不同。比方说,如果是数据库访问慢,那么就要看是不是有锁表的情况、是不是有全表扫描、索引加得是否合适、是否有 JOIN 操作、需不需要加缓存,等等;如果是网络的问题,就要看网络的参数是否有优化的空间,抓包来看是否有大量的超时重传,网卡是否有大量丢包等。

总而言之,「兵来将挡水来土掩」,我们需要制定不同的性能优化方案来应对不同的性能问题。

课程小结

今天,我带你了解了性能的原则、度量指标,以及在高并发下优化性能的基本思路。性能优化是一个很大的话题,只用短短一讲是完全不够的,所以我会在后面的课程中详细介绍其中的某些方面,比方说我们如何用缓存优化系统的读取性能,如何使用消息队列优化系统的写入性能等等。

有时候你在遇到性能问题的时候会束手无策,从今天的课程中你可以得到一些启示,在这里我给你总结出几点:

  • 数据优先,你做一个新的系统在上线之前一定要把性能监控系统做好;
  • 掌握一些性能优化工具和方法,这就需要在工作中不断的积累;
  • 计算机基础知识很重要,比如说网络知识、操作系统知识等等,掌握了基础知识才能让你在优化过程中抓住性能问题的关键,也能在性能优化过程中游刃有余。

4.4 - CH04-基础-高可用

本节课,我会继续带你了解高并发系统设计的第二个目标——高可用性。你需要在本节课对提升系统可用性的思路和方法有一个直观的了解,这样,当后续对点讲解这些内容时,你能马上反应过来,你的系统在遇到可用性的问题时,也能参考这些方法进行优化。

高可用性(High Availability,HA) 是你在系统设计时经常会听到的一个名词,它指的是系统具备较高的无故障运行的能力

我们在很多开源组件的文档中看到的 HA 方案就是提升组件可用性,让系统免于宕机无法服务的方案。比如,你知道 Hadoop 1.0 中的 NameNode 是单点的,一旦发生故障则整个集群就会不可用;而在 Hadoop2 中提出的 NameNode HA 方案就是同时启动两个 NameNode,一个处于 Active 状态,另一个处于 Standby 状态,两者共享存储,一旦 Active NameNode 发生故障,则可以将 Standby NameNode 切换成 Active 状态继续提供服务,这样就增强了 Hadoop 的持续无故障运行的能力,也就是提升了它的可用性。

通常来讲,一个高并发大流量的系统,系统出现故障比系统性能低更损伤用户的使用体验。想象一下,一个日活用户过百万的系统,一分钟的故障可能会影响到上千的用户。而且随着系统日活的增加,一分钟的故障时间影响到的用户数也随之增加,系统对于可用性的要求也会更高。所以今天,我就带你了解一下在高并发下,我们如何来保证系统的高可用性,以便给你的系统设计提供一些思路。

可用性的度量

可用性是一个抽象的概念,你需要知道要如何来度量它,与之相关的概念是: MTBF 和 MTTR。

MTBF(Mean Time Between Failure) 是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。

MTTR(Mean Time To Repair) 表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。

可用性与 MTBF 和 MTTR 的值息息相关,我们可以用下面的公式表示它们之间的关系:

Availability = MTBF / (MTBF + MTTR)

这个公式计算出的结果是一个比例,而这个比例代表着系统的可用性。一般来说,我们会使用几个九来描述系统的可用性。

NAME

其实通过这张图你可以发现,一个九和两个九的可用性是很容易达到的,只要没有蓝翔技校的铲车搞破坏,基本上可以通过人肉运维的方式实现。

三个九之后,系统的年故障时间从 3 天锐减到 8 小时。到了四个九之后,年故障时间缩减到 1 小时之内。在这个级别的可用性下,你可能需要建立完善的运维值班体系、故障处理流程和业务变更流程。你可能还需要在系统设计上有更多的考虑。比如,在开发中你要考虑,如果发生故障,是否不用人工介入就能自动恢复。当然了,在工具建设方面,你也需要多加完善,以便快速排查故障原因,让系统快速恢复。

到达五个九之后,故障就不能靠人力恢复了。想象一下,从故障发生到你接收报警,再到你打开电脑登录服务器处理问题,时间可能早就过了十分钟了。所以这个级别的可用性考察的是系统的容灾和自动恢复的能力,让机器来处理故障,才会让可用性指标提升一个档次。

一般来说,我们的核心业务系统的可用性,需要达到四个九,非核心系统的可用性最多容忍到三个九。在实际工作中,你可能听到过类似的说法,只是不同级别,不同业务场景的系统对于可用性要求是不一样的。

目前,你已经对可用性的评估指标有了一定程度的了解了,接下来,我们来看一看高可用的系统设计需要考虑哪些因素。

高可用系统设计的思路

一个成熟系统的可用性需要从系统设计和系统运维两方面来做保障,两者共同作用,缺一不可。那么如何从这两方面入手,解决系统高可用的问题呢?

1. 系统设计

Design for failure 是我们做高可用系统设计时秉持的第一原则。在承担百万 QPS 的高并发系统中,集群中机器的数量成百上千台,单机的故障是常态,几乎每一天都有发生故障的可能。

未雨绸缪才能决胜千里。我们在做系统设计的时候,要把发生故障作为一个重要的考虑点,预先考虑如何自动化地发现故障,发生故障之后要如何解决。当然了,除了要有未雨绸缪的思维之外,我们还需要掌握一些具体的优化方法,比如 failover(故障转移)、超时控制以及降级和限流。

failover(故障转移)

一般来说,发生 failover 的节点可能有两种情况:

  1. 是在 完全对等 的节点之间做 failover。
  2. 是在 不对等 的节点之间,即系统中存在主节点也存在备节点。

在对等节点之间做 failover 相对来说简单些。在这类系统中所有节点都承担读写流量,并且节点中不保存状态,每个节点都可以作为另一个节点的镜像。在这种情况下,如果访问某一个节点失败,那么简单地随机访问另一个节点就好了。

举个例子,Nginx 可以配置当某一个 Tomcat 出现大于 500 的请求的时候,重试请求另一个 Tomcat 节点,就像下面这样:

NAME

针对不对等节点的 failover 机制会复杂很多。比方说我们有一个主节点,有多台备用节点,这些备用节点可以是热备(同样在线提供服务的备用节点),也可以是冷备(只作为备份使用),那么我们就需要在代码中控制如何检测主备机器是否故障,以及如何做主备切换。

使用最广泛的故障检测机制是「心跳」。你可以在客户端上定期地向主节点发送心跳包,也可以从备份节点上定期发送心跳包。当一段时间内未收到心跳包,就可以认为主节点已经发生故障,可以触发选主的操作。

选主的结果需要在多个备份节点上达成一致,所以会使用某一种分布式一致性算法,比方说 Paxos,Raft。

调用超时控制

除了故障转移以外,对于系统间调用超时的控制也是高可用系统设计的一个重要考虑方面。

复杂的高并发系统通常会有很多的系统模块组成,同时也会依赖很多的组件和服务,比如说缓存组件,队列服务等等。它们之间的调用最怕的就是延迟而非失败 ,因为失败通常是瞬时的,可以通过重试的方式解决。而一旦调用某一个模块或者服务发生比较大的延迟,调用方就会阻塞在这次调用上,它已经占用的资源得不到释放。当存在大量这种阻塞请求时,调用方就会因为用尽资源而挂掉。

在系统开发的初期,超时控制通常不被重视,或者是没有方式来确定正确的超时时间。

我之前经历过一个项目, 模块之间通过 RPC 框架来调用,超时时间是默认的 30 秒。平时系统运行得非常稳定,可是一旦遇到比较大的流量,RPC 服务端出现一定数量慢请求的时候,RPC 客户端线程就会大量阻塞在这些慢请求上长达 30 秒,造成 RPC 客户端用尽调用线程而挂掉。后面我们在故障复盘的时候发现这个问题后,调整了 RPC,数据库,缓存以及调用第三方服务的超时时间,这样在出现慢请求的时候可以触发超时,就不会造成整体系统雪崩。

既然要做超时控制,那么我们怎么来确定超时时间呢?这是一个比较困难的问题。

超时时间短了,会造成大量的超时错误,对用户体验产生影响;超时时间长了,又起不到作用。 我建议你通过收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。 如果没有调用的日志,那么你只能按照经验值来指定超时时间。不过,无论你使用哪种方式,超时时间都不是一成不变的,需要在后面的系统维护过程中不断地修改。

超时控制实际上就是不让请求一直保持,而是在经过一定时间之后让请求失败,释放资源给接下来的请求使用。这对于用户来说是有损的,但是却是必要的,因为它牺牲了少量的请求却保证了整体系统的可用性。而我们还有另外两种有损的方案能保证系统的高可用,它们就是降级和限流。

降级

降级是为了保证核心服务的稳定而牺牲非核心服务的做法。 比方说我们发一条微博会先经过反垃圾服务检测,检测内容是否是广告,通过后才会完成诸如写数据库等逻辑。

反垃圾的检测是一个相对比较重的操作,因为涉及到非常多的策略匹配,在日常流量下虽然会比较耗时却还能正常响应。但是当并发较高的情况下,它就有可能成为瓶颈,而且它也不是发布微博的主体流程,所以我们可以暂时关闭反垃圾服务检测,这样就可以保证主体的流程更加稳定。

限流

限流完全是另外一种思路,它通过对并发的请求进行限速来保护系统。

比如对于 Web 应用,我限制单机只能处理每秒 1000 次的请求,超过的部分直接返回错误给客户端。虽然这种做法损害了用户的使用体验,但是它是在极端并发下的无奈之举,是短暂的行为,因此是可以接受的。

实际上,无论是降级还是限流,在细节上还有很多可供探讨的地方,我会在后面的课程中,随着系统的不断演进深入地剖析,在基础篇里就不多说了。

2. 系统运维

在系统设计阶段为了保证系统的可用性可以采取上面的几种方法,那在系统运维的层面又能做哪些事情呢?其实,我们可以从 灰度发布、故障演练 两个方面来考虑如何提升系统的可用性。

你应该知道,在业务平稳运行过程中,系统是很少发生故障的,90% 的故障是发生在上线变更阶段的。比方说,你上了一个新的功能,由于设计方案的问题,数据库的慢请求数翻了一倍,导致系统请求被拖慢而产生故障。

如果没有变更,数据库怎么会无缘无故地产生那么多的慢请求呢?因此,为了提升系统的可用性,重视变更管理尤为重要。而除了提供必要回滚方案,以便在出现问题时快速回滚恢复之外, 另一个主要的手段就是灰度发布。

灰度发布

灰度发布指的是系统的变更不是一次性地推到线上的,而是按照一定比例逐步推进的。一般情况下,灰度发布是以机器维度进行的。比方说,我们先在 10% 的机器上进行变更,同时观察 Dashboard 上的系统性能指标以及错误日志。如果运行了一段时间之后系统指标比较平稳并且没有出现大量的错误日志,那么再推动全量变更。

灰度发布给了开发和运维同学绝佳的机会,让他们能在线上流量上观察变更带来的影响,是保证系统高可用的重要关卡。

灰度发布是在系统正常运行条件下,保证系统高可用的运维手段,那么我们如何知道发生故障时系统的表现呢?这里就要依靠另外一个手段: 故障演练。

故障演练

故障演练指的是对系统进行一些破坏性的手段,观察在出现局部故障时,整体的系统表现是怎样的,从而发现系统中存在的,潜在的可用性问题。

一个复杂的高并发系统依赖了太多的组件,比方说磁盘,数据库,网卡等,这些组件随时随地都可能会发生故障,而一旦它们发生故障,会不会如蝴蝶效应一般造成整体服务不可用呢?我们并不知道,因此,故障演练尤为重要。

在我来看, 故障演练和时下比较流行的“混沌工程”的思路如出一辙, 作为混沌工程的鼻祖,Netfix 在 2010 年推出的 Chaos Monkey 工具就是故障演练绝佳的工具。它通过在线上系统上随机地关闭线上节点来模拟故障,让工程师可以了解,在出现此类故障时会有什么样的影响。

当然,这一切是以你的系统可以抵御一些异常情况为前提的。如果你的系统还没有做到这一点,那么 我建议你 另外搭建一套和线上部署结构一模一样的线下系统,然后在这套系统上做故障演练,从而避免对生产系统造成影响。

课程小结

本节课我带你了解了如何度量系统的可用性,以及在做高并发系统设计时如何来保证高可用。

说了这么多,你可以看到从开发和运维角度上来看,提升可用性的方法是不同的:

  • 开发 注重的是如何处理故障,关键词是 冗余和取舍

    冗余指的是有备用节点,集群来顶替出故障的服务,比如文中提到的故障转移,还有多活架构等等;取舍指的是丢卒保车,保障主体服务的安全。

  • 运维角度 来看则更偏保守,注重的是如何避免故障的发生

    比如更关注变更管理以及如何做故障的演练。

两者结合起来才能组成一套完善的高可用体系。

你还需要注意的是,提高系统的可用性有时候是以牺牲用户体验或者是牺牲系统性能为前提的,也需要大量人力来建设相应的系统,完善机制。所以我们要把握一个度,不该做过度的优化。就像我在文中提到的,核心系统四个九的可用性已经可以满足需求,就没有必要一味地追求五个九甚至六个九的可用性。

另外,一般的系统或者组件都是追求极致的性能的,那么有没有不追求性能,只追求极致的可用性的呢?答案是有的。比如配置下发的系统,它只需要在其它系统启动时提供一份配置即可,所以秒级返回也可,十秒钟也 OK,无非就是增加了其它系统的启动速度而已。但是,它对可用性的要求是极高的,甚至会到六个九,原因是配置可以获取的慢,但是不能获取不到。我给你举这个例子是想让你了解, 可用性和性能有时候是需要做取舍的,但如何取舍就要视不同的系统而定,不能一概而论了。

4.5 - CH05-基础-易扩展

从架构设计上来说,高可扩展性是一个设计的指标,它表示可以通过增加机器的方式来线性提高系统的处理能力,从而承担更高的流量和并发

你可能会问:在架构设计之初,为什么不预先考虑好使用多少台机器,支持现有的并发呢?这个问题我在第 3 节中提到过,答案 是峰值的流量不可控

一般来说,基于成本考虑,在业务平稳期,我们会预留 30%~50% 的冗余以应对运营活动或者推广可能带来的峰值流量,但是当有一个突发事件发生时,流量可能瞬间提升到 2~3 倍甚至更高,我们还是以微博为例。

鹿晗和关晓彤互圈公布恋情,大家会到两个人的微博下面,或围观,或互动,微博的流量短时间内增长迅速,微博信息流也短暂出现无法刷出新的消息的情况。

那我们要如何应对突发的流量呢?架构的改造已经来不及了,最快的方式就是堆机器。不过我们需要保证,扩容了三倍的机器之后,相应的我们的系统也能支撑三倍的流量。有的人可能会产生疑问:这不是显而易见的吗?很简单啊。真的是这样吗?我们来看看做这件事儿难在哪儿。

为什么提升扩展性会很复杂

在上一讲中,我提到可以在单机系统中通过增加处理核心的方式,来增加系统的并行处理能力,但这个方式并不总生效。因为当并行的任务数较多时,系统会因为争抢资源而达到性能上的拐点,系统处理能力不升反降。

而对于由多台机器组成的集群系统来说也是如此。集群系统中,不同的系统分层上可能存在一些 「瓶颈点」,这些瓶颈点制约着系统的横线扩展能力。这句话比较抽象,我举个例子你就明白了。

比方说,你系统的流量是每秒 1000 次请求,对数据库的请求量也是每秒 1000 次。如果流量增加 10 倍,虽然系统可以通过扩容正常服务,数据库却成了瓶颈。再比方说,单机网络带宽是 50Mbps,那么如果扩容到 30 台机器,前端负载均衡的带宽就超过了千兆带宽的限制,也会成为瓶颈点。那么,我们的系统中存在哪些服务会成为制约系统扩展的重要因素呢?

其实,无状态的服务和组件更易于扩展,而像 MySQL 这种存储服务是有状态的,就比较难以扩展。因为向存储集群中增加或者减少机器时,会涉及大量数据的迁移,而一般传统的关系型数据库都不支持。这就是为什么提升系统扩展性会很复杂的主要原因。

除此之外,从例子中你可以看到,我们需要站在整体架构的角度,而不仅仅是业务服务器的角度来考虑系统的扩展性 。 所以说,数据库、缓存、依赖的第三方、负载均衡、交换机带宽等等 都是系统扩展时需要考虑的因素。我们要知道系统并发到了某一个量级之后,哪一个因素会成为我们的瓶颈点,从而针对性地进行扩展。

针对这些复杂的扩展性问题,我提炼了一些系统设计思路,供你了解。

高可扩展性的设计思路

拆分 是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。相对于大系统来说,考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化,这就是我们的思路。

但对于不同类型的模块,我们在拆分上遵循的原则是不一样的。我给你举一个简单的例子,假如你要设计一个社区,那么社区会有几个模块呢?可能有 5 个模块。

  • 用户:负责维护社区用户信息,注册,登陆等;
  • 关系:用户之间关注、好友、拉黑等关系的维护;
  • 内容:社区发的内容,就像朋友圈或者微博的内容;
  • 评论、赞:用户可能会有的两种常规互动操作;
  • 搜索:用户的搜索,内容的搜索。

而部署方式遵照最简单的三层部署架构,负载均衡负责请求的分发,应用服务器负责业务逻辑的处理,数据库负责数据的存储落地。这时,所有模块的业务代码都混合在一起了,数据也都存储在一个库里。

NAME

1. 存储层的扩展性

无论是存储的数据量,还是并发访问量,不同的业务模块之间的量级相差很大,比如说成熟社区中,关系的数据量是远远大于用户数据量的,但是用户数据的访问量却远比关系数据要大。所以假如存储目前的瓶颈点是容量,那么我们只需要针对关系模块的数据做拆分就好了,而不需要拆分用户模块的数据。 所以存储拆分首先考虑的维度是业务维度。

拆分之后,这个简单的社区系统就有了用户库、内容库、评论库、点赞库和关系库。这么做还能隔离故障,某一个库「挂了」不会影响到其它的数据库。

NAME

按照业务拆分,在一定程度上提升了系统的扩展性,但系统运行时间长了之后,单一的业务数据库在容量和并发请求量上仍然会超过单机的限制。 这时,我们就需要针对数据库做第二次拆分。

这次拆分是按照数据特征做水平的拆分 ,比如说我们可以给用户库增加两个节点,然后按照某些算法将用户的数据拆分到这三个库里面,具体的算法我会在后面讲述数据库分库分表时和你细说。

水平拆分之后,我们就可以让数据库突破单机的限制了。但这里要注意,我们不能随意地增加节点,因为一旦增加节点就需要手动地迁移数据,成本还是很高的。所以基于长远的考虑,我们最好一次性增加足够的节点以避免频繁地扩容

当数据库按照业务和数据维度拆分之后,我们 尽量不要使用事务。因为当一个事务中同时更新不同的数据库时,需要使用二阶段提交,来协调所有数据库要么全部更新成功,要么全部更新失败。这个协调的成本会随着资源的扩展不断升高,最终达到无法承受的程度。

说完了存储层的扩展性,我们来看看业务层是如何做到易于扩展的。

2. 业务层的扩展性

我们一般会从三个维度考虑业务层的拆分方案,它们分别是:业务纬度重要性纬度请求来源纬度

首先,我们需要把相同业务的服务拆分成单独的业务池,比方说上面的社区系统中,我们可以按照业务的维度拆分成用户池、内容池、关系池、评论池、点赞池和搜索池。

每个业务依赖独自的数据库资源,不会依赖其它业务的数据库资源。这样当某一个业务的接口成为瓶颈时,我们只需要扩展业务的池子,以及确认上下游的依赖方就可以了,这样就大大减少了扩容的复杂度。

NAME

除此之外,我们还可以根据业务接口的重要程度,把业务分为核心池和非核心池 (池子就是一组机器组成的集群) 。打个比方,就关系池而言,关注、取消关注接口相对重要一些,可以放在核心池里面;拉黑和取消拉黑的操作就相对不那么重要,可以放在非核心池里面。这样,我们可以优先保证核心池的性能,当整体流量上升时优先扩容核心池,降级部分非核心池的接口,从而保证整体系统的稳定性。

NAME

最后,你还可以根据接入客户端类型的不同做业务池的拆分。比如说,服务于客户端接口的业务可以定义为外网池,服务于小程序或者 HTML5 页面的业务可以定义为 H5 池,服务于内部其它部门的业务可以定义为内网池,等等。

课程小结

本节课我带你了解了提升系统扩展性的复杂度以及系统拆分的思路。拆分看起来比较简单,可是什么时候做拆分,如何做拆分还是有很多细节考虑的。

未做拆分的系统虽然可扩展性不强,但是却足够简单,无论是系统开发还是运行维护都不需要投入很大的精力。拆分之后,需求开发需要横跨多个系统多个小团队,排查问题也需要涉及多个系统,运行维护上,可能每个子系统都需要有专人来负责,对于团队是一个比较大的考验。这个考验是我们必须要经历的一个大坎,需要我们做好准备。

4.6 - CH07-数据库-池化

来想象这样一个场景,一天,公司 CEO 把你叫到会议室,告诉你公司看到了一个新的商业机会,希望你能带领一名兄弟,迅速研发出一套面向某个垂直领域的电商系统。

在人手紧张,时间不足的情况下,为了能够完成任务,你毫不犹豫地采用了 最简单的架构 :前端一台 Web 服务器运行业务代码,后端一台数据库服务器存储业务数据。

NAME

这个架构图是我们每个人最熟悉的,最简单的架构原型,很多系统在一开始都是长这样的,只是随着业务复杂度的提高,架构做了叠加,然后看起来就越来越复杂了。

再说回我们的垂直电商系统,系统一开始上线之后,虽然用户量不大,但运行平稳,你很有成就感,不过 CEO 觉得用户量太少了,所以紧急调动运营同学做了一次全网的流量推广。

这一推广很快带来了一大波流量, 但这时,系统的访问速度开始变慢。

分析程序的日志之后,你发现系统慢的原因 出现在和数据库的交互上 。因为你们数据库的调用方式是先获取数据库的连接,然后依靠这条连接从数据库中查询数据,最后关闭连接释放数据库资源。这种调用方式下,每次执行 SQL 都需要重新建立连接,所以你怀疑, 是不是频繁地建立数据库连接耗费时间长导致了访问慢的问题

那么为什么频繁创建连接会造成响应时间慢呢?来看一个实际的测试。

我用

# -i: 指定网卡
tcpdump -i bond0 -nn -tttt port 4490

命令抓取了线上 MySQL 建立连接的网络包来做分析,从抓包结果来看,整个 MySQL 的连接过程可以分为两部分:

NAME
  • 第一部分是前三个数据包

    第一个数据包是客户端向服务端发送的一个 SYN 包,

    第二个包是服务端回给客户端的 ACK 包以及一个 SYN 包,

    第三个包是客户端回给服务端的 ACK 包,熟悉 TCP 协议的同学可以看出这是一个 TCP 的三次握手过程。

  • 第二部分是 MySQL 服务端校验客户端密码的过程。

    其中第一个包是服务端发给客户端要求认证的报文,

    第二和第三个包是客户端将加密后的密码发送给服务端的包,

    最后两个包是服务端回给客户端认证 OK 的报文。

从图中,你可以看到整个连接过程大概消耗了 4ms(969012-964904)。

那么单条 SQL 执行时间是多少呢?我们统计了一段时间的 SQL 执行时间,发现 SQL 的平均执行时间大概是 1ms,也就是说相比于 SQL 的执行,MySQL 建立连接的过程是比较耗时的。这在请求量小的时候其实影响不大,因为无论是建立连接还是执行 SQL,耗时都是毫秒级别的。可是请求量上来之后,如果按照原来的方式建立一次连接只执行一条 SQL 的话,1s 只能执行 200 次数据库的查询,而数据库建立连接的时间占了其中 4/5。

那这时你要怎么做呢?

一番谷歌搜索之后,你发现解决方案也很简单,只要使用连接池将数据库连接预先建立好,这样在使用的时候就不需要频繁地创建连接了。调整之后,你发现 1s 就可以执行 1000 次的数据库查询,查询性能大大的提升了。

用连接池预先建立数据库连接

虽然短时间解决了问题,不过你还是想彻底搞明白解决问题的核心原理,于是又开始补课。

其实,在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心, 我就以数据库连接池为例,来说明一下连接池管理的关键点。

数据库连接池有两个最重要的配置: 最小连接数和最大连接数, 它们控制着从连接池中获取连接的流程:

  • 如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
  • 如果连接池中有空闲连接则复用空闲连接;
  • 如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
  • 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;
  • 如果等待超过了这个设定时间则向用户抛出错误。

这个流程你不用死记,非常简单。你可以停下来想想如果你是连接池的设计者你会怎么设计,有哪些关键点,这个设计思路在我们以后的架构设计中经常会用到。

为了方便你理解性记忆这个流程,我来举个例子。

假设你在机场里经营着一家按摩椅的小店,店里一共摆着 10 台按摩椅(类比最大连接数),为了节省成本(按摩椅费电),你平时会保持店里开着 4 台按摩椅(最小连接数),其他 6 台都关着。

有顾客来的时候,如果平时保持启动的 4 台按摩椅有空着的,你直接请他去空着的那台就好了。但如果顾客来的时候,4 台按摩椅都不空着,那你就会新启动一台,直到你的 10 台按摩椅都被用完。

那 10 台按摩椅都被用完之后怎么办呢?你会告诉用户,稍等一会儿,我承诺你 5 分钟(等待时间)之内必定能空出来,然后第 11 位用户就开始等着。这时,会有两个结果:如果 5 分钟之内有空出来的,那顾客直接去空出来的那台按摩椅就可以了,但如果用户等了 5 分钟都没空出来,那你就得赔礼道歉,让用户去其他店再看看。

对于数据库连接池,根据我的经验,一般在线上我建议最小连接数控制在 10 左右,最大连接数控制在 20~30 左右即可。

在这里,你需要注意池子中连接的维护问题,也就是我提到的按摩椅。有的按摩椅虽然开着,但有的时候会有故障,一般情况下, 按摩椅故障 的原因可能有以下几种:

  1. 数据库的域名对应的 IP 发生了变更,池子的连接还是使用旧的 IP,当旧的 IP 下的数据库服务关闭后,再使用这个连接查询就会发生错误;
  2. MySQL 有个参数是 wait_timeout,控制着当数据库连接闲置多长时间后,数据库会主动的关闭这条连接。这个机制对于数据库使用方是无感知的,所以当我们使用这个被关闭的连接时就会发生错误。

那么,作为按摩椅店老板,你怎么保证你启动着的按摩椅一定是可用的呢?

  1. 启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送 select 1 的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用, 也是我比较推荐的方式。
  2. 在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引入多余的开销, 在线上系统中还是尽量不要开启,在测试服务上可以使用。

至此,你彻底搞清楚了连接池的工作原理。可是,当你刚想松一口气的时候,CEO 又提出了一个新的需求。你分析了一下这个需求,发现在一个非常重要的接口中,你需要访问 3 次数据库。根据经验判断,你觉得这里未来肯定会成为系统瓶颈。

进一步想,你觉得可以创建多个线程来并行处理与数据库之间的交互,这样速度就能快了。不过,因为有了上次数据库的教训,你想到在高并发阶段,频繁创建线程的开销也会很大,于是顺着之前的思路继续想,猜测到了线程池。

用线程池预先创建线程

果不其然,JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要的参数:coreThreadCountmaxThreadCount,这两个参数控制着线程池的执行过程。它的执行原理类似上面我们说的按摩椅店的模式,我这里再给你描述下,以加深你的记忆:

  • 如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程;
  • 如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执行;
  • 当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount;
  • 当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃了。
NAME

这个任务处理流程看似简单,实际上有很多坑,你在使用的时候一定要注意。

首先, JDK 实现的这个线程池优 先把任务放入队列暂存起来,而不是创建更多的线程 ,它比较适用于执行 CPU 密集型的任务,也就是需要执行大量 CPU 运算的任务。这是为什么呢?因为执行 CPU 密集型的任务时 CPU 比较繁忙,因此只需要创建和 CPU 核数相当的线程就好了,多了反而会造成线程上下文切换,降低任务执行效率。所以当当前线程数超过核心线程数时,线程池不会增加线程,而是放在队列里等待核心线程空闲下来。

但是,我们平时开发的 Web 系统通常都有大量的 IO 操作,比方说查询数据库、查询缓存等等。任务在执行 IO 操作的时候 CPU 就空闲了下来,这时如果增加执行任务的线程数而不是把任务暂存在队列中,就可以在单位时间内执行更多的任务,大大提高了任务执行的吞吐量。所以你看 Tomcat 使用的线程池就不是 JDK 原生的线程池,而是做了一些改造,当线程数超过 coreThreadCount 之后会优先创建线程,直到线程数到达 maxThreadCount,这样就比较适合于 Web 系统大量 IO 操作的场景了,你在实际运用过程中也可以参考借鉴。

其次,线程池中使用的队列的堆积量也是我们需要监控的重要指标 ,对于实时性要求比较高的任务来说,这个指标尤为关键。

我在实际项目中就曾经遇到过任务被丢给线程池之后,长时间都没有被执行的诡异问题。 最初,我认为这是代码的 Bug 导致的,后来经过排查发现,是因为线程池的 coreThreadCount 和 maxThreadCount 设置的比较小,导致任务在线程池里面大量的堆积,在调大了这两个参数之后问题就解决了。跳出这个坑之后,我就把重要线程池的队列任务堆积量 ,作为一个重要的监控指标放到了系统监控大屏上。

最后, 如果你使用线程池请一定记住 不要使用无界队列(即没有设置固定大小的队列) 。也许你会觉得使用了无界队列后,任务就永远不会被丢弃,只要任务对实时性要求不高,反正早晚有消费完的一天。但是,大量的任务堆积会占用大量的内存空间,一旦内存空间被占满就会频繁地触发 Full GC,造成服务不可用,我之前排查过的一次 GC 引起的宕机,起因就是系统中的一个线程池使用了无界队列。

理解了线程池的关键要点,你在系统里加上了这个特性,至此,系统稳定,你圆满完成了公司给你的研发任务。

这时,你回顾一下这两种技术,会发现它们都有一个 共同点: 它们所管理的对象,无论是连接还是线程,它们的创建过程都比较耗时,也比较消耗系统资源 。所以,我们把它们放在一个池子里统一管理起来,以达到 提升性能和资源复用的目的

这是一种常见的软件设计思想,叫做池化技术, 它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本,总之是好处多多。

不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间。

可这些缺陷相比池化技术的优势来说就比较微不足道了,只要我们确认要使用的对象在创建时确实比较耗时或者消耗资源,并且这些对象也确实会被频繁地创建和销毁,我们就可以使用池化技术来优化。

课程小结

本节课,我模拟了研发垂直电商系统最原始的场景,在遇到数据库查询性能下降的问题时,我们使用数据库连接池解决了频繁创建连接带来的性能问题,后面又使用线程池提升了并行查询数据库的性能。

其实,连接池和线程池你并不陌生,不过你可能对它们的原理和使用方式上还存在困惑或者误区,我在面试时,就发现有很多的同学对线程池的基本使用方式都不了解。借用这节课,我想再次强调的重点是:

  • 池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。
  • 池子中的对象需要在使用之前预先初始化完成,这叫做 池子的预热 ,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。
  • 池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。

4.7 - CH08-数据库-主从分离

上节课,我们用池化技术解决了数据库连接复用的问题,这时,你的垂直电商系统虽然整体架构上没有变化,但是和数据库交互的过程有了变化,在你的 Web 工程和数据库之间增加了数据库连接池,减少了频繁创建连接的成本,从上节课的测试来看性能上可以提升 80%。现在的架构图如下所示:

NAME

此时,你的数据库还是单机部署,依据一些云厂商的 Benchmark 的结果,在 4 核 8G 的机器上运 MySQL 5.7 时,大概可以支撑 500 的 TPS 和 10000 的 QPS。这时,运营负责人说正在准备双十一活动,并且公司层面会继续投入资金在全渠道进行推广,这无疑会引发查询量骤然增加的问题。那么今天,我们就一起来看看当查询请求增加时,应该如何做主从分离来解决问题。

主从读写分离

其实,大部分系统的访问模型是 读多写少,读写请求量的差距可能达到几个数量级。

这很好理解,刷朋友圈的请求量肯定比发朋友圈的量大,淘宝上一个商品的浏览量也肯定远大于它的下单量。因此,我们优先考虑数据库如何抗住更高的查询请求,那么首先你需要把读写流量区分开,因为这样才方便针对读流量做单独的扩展,这就是我们所说的主从读写分离。

它其实是个流量分离的问题,就好比道路交通管制一样,一个四车道的大马路划出三个车道给领导外宾通过,另外一个车道给我们使用,优先保证领导先行,就是这个道理。

这个方法本身是一种常规的做法,即使在一个大的项目中,它也是一个应对数据库突发读流量的有效方法。

我目前的项目中就曾出现过前端流量突增导致从库负载过高的问题,DBA 兄弟会优先做一个从库扩容上去,这样对数据库的读流量就会落入到多个从库上,从库的负载就降了下来,然后研发同学再考虑使用什么样的方案将流量挡在数据库层之上。

主从读写的两个技术关键点

一般来说在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或者多份,并且写入到其它的数据库服务器中,原始的数据库我们称为 主库,主要负责数据的写入,拷贝的目标数据库称为 从库,主要负责支持数据查询。可以看到,主从读写分离有两个技术上的关键点:

  1. 一个是数据的拷贝,我们称为主从复制;
  2. 在主从分离的情况下,我们 如何屏蔽主从分离带来的访问数据库方式的变化,让开发同学像是在使用单一数据库一样。

接下来,我们分别来看一看。

1. 主从复制

我先以 MySQL 为例介绍一下主从复制。

MySQL 的主从复制是依赖于 binlog 的,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上二进制日志文件。主从复制就是将 binlog 中的数据从主库传输到从库上 ,一般这个过程是异步的,即主库上的操作不会等待 binlog 同步的完成。

主从复制的过程是这样的

  1. 首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中
  2. 而主库也会创建一个 log dump 线程来发送 binlog 给从库;
  3. 同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。

在这个方案中,使用独立的 log dump 线程是一种异步的方式,可以避免对主库的主体更新流程产生影响,而从库在接收到信息后并不是写入从库的存储中,是写入一个 relay log,是避免写入从库实际存储会比较耗时,最终造成从库和主库延迟变长。

NAME

你会发现,基于性能的考虑,主库的写入流程并没有等待主从同步完成就会返回结果,那么在极端的情况下,比如说主库上 binlog 还没有来得及刷新到磁盘上就出现了磁盘损坏或者机器掉电,就会导致 binlog 的丢失,最终造成主从数据的不一致。 不过,这种情况出现的概率很低,对于互联网的项目来说是可以容忍的。

做了主从复制之后,我们就可以在写入时只写主库,在读数据时只读从库,这样即使写请求会锁表或者锁记录,也不会影响到读请求的执行。同时呢,在读流量比较大的情况下,我们可以部署多个从库共同承担读流量,这就是所说的 一主多从 部署方式,在你的垂直电商项目中就可以通过这种方式来抵御较高的并发读流量。另外,从库也可以当成一个备库来使用,以避免主库故障导致数据丢失。

那么你可能会说,是不是我无限制地增加从库的数量就可以抵抗大量的并发呢? 实际上并不是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3~5 个从库

当然,主从复制也有一些缺陷, 除了带来了部署上的复杂度,还有就是会带来一定的主从同步的延迟,这种延迟有时候会对业务产生一定的影响,我举个例子你就明白了。

在发微博的过程中会有些同步的操作,像是更新数据库的操作,也有一些异步的操作,比如说将微博的信息同步给审核系统,所以我们在更新完主库之后,会将微博的 ID 写入消息队列,再由队列处理机依据 ID 在从库中获取微博信息再发送给审核系统。 此时如果主从数据库存在延迟,会导致在从库中获取不到微博信息,整个流程会出现异常。

NAME

这个问题解决的思路有很多,核心思想就是尽量不去从库中查询信息 ,纯粹以上面的例子来说,我就有三种解决方案:

  1. 第一种方案是数据的冗余。

    你可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。

  2. 第二种方案是使用缓存。

    我可以在同步写数据库的同时,也把微博的数据写入到 Memcached 缓存里面,这样队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。

  3. 最后一种方案是查询主库。

    我可以在队列处理机中不查询从库而改为查询主库。不过,这种方式使用起来要慎重,要明确查询的量级不会很大,是在主库的可承受范围之内,否则会对主库造成比较大的压力。

我会优先考虑第一种方案,因为这种方式足够简单, 不过可能造成单条消息比较大,从而增加了消息发送的带宽和时间

缓存的方案比较 适合新增数据的场景,在更新数据的场景下, 先更新缓存可能会造成数据的不一致 ,比方说两个线程同时更新数据:

  1. 线程 A 把缓存中的数据更新为 1
  2. 此时另一个线程 B 把缓存中的数据更新为 2,然后线程 B 又更新数据库中的数据为 2,
  3. 此时线程 A 更新数据库中的数据为 1,这样数据库中的值(1)和缓存中的值(2)就不一致了。

最后,若非万不得已的情况下,我不会使用第三种方案。原因是这种方案要提供一个查询主库的接口,在团队开发的过程中,你很难保证其他同学不会滥用这个方法,而一旦主库承担了大量的读请求导致崩溃,那么对于整体系统的影响是极大的。

所以对这三种方案来说,你要有所取舍,根据实际项目情况做好选择。

另外,主从同步的延迟,是我们排查问题时很容易忽略的一个问题。 有时候我们遇到从数据库中获取不到信息的诡异问题时,会纠结于代码中是否有一些逻辑会把之前写入的内容删除,但是你又会发现,过了一段时间再去查询时又可以读到数据了,这基本上就是主从延迟在作怪。所以,一般我们会把从库落后的时间作为一个重点的数据库指标做监控和报警,正常的时间是在毫秒级别,一旦落后的时间达到了秒级别就需要告警了。

2. 如何访问数据库

我们已经使用主从复制的技术将数据复制到了多个节点,也实现了数据库读写的分离,这时,对于数据库的使用方式发生了变化。以前只需要使用一个数据库地址就好了,现在需要使用一个主库地址和多个从库地址,并且需要区分写入操作和查询操作,如果结合下一节课中要讲解的内容 分库分表,复杂度会提升更多。 为了降低实现的复杂度,业界涌现了很多数据库中间件来解决数据库的访问问题,这些中间件可以分为两类。

  1. 第一类以淘宝的 TDDL( Taobao Distributed Data Layer)为代表,以代码形式内嵌运行在应用程序内部

    你可以把它看成是一种数据源的代理,它的配置管理着多个数据源,每个数据源对应一个数据库,可能是主库,可能是从库。当有一个数据库请求时,中间件将 SQL 语句发给某一个指定的数据源来处理,然后将处理结果返回。

    这一类中间件的优点是简单易用,没有多余的部署成本,因为它是植入到应用程序内部,与应用程序一同运行的,所以比较适合运维能力较弱的小团队使用;缺点是缺乏多语言的支持,目前业界这一类的主流方案除了 TDDL,还有早期的网易 DDB,它们都是 Java 语言开发的,无法支持其他的语言。另外,版本升级也依赖使用方更新,比较困难。

  2. 另一类是单独部署的代理层方案

    这一类方案代表比较多,如早期阿里巴巴开源的 Cobar,基于 Cobar 开发出来的 Mycat,360 开源的 Atlas,美团开源的基于 Atlas 开发的 DBProxy 等等。

    这一类中间件部署在独立的服务器上,业务代码如同在使用单一数据库一样使用它,实际上它内部管理着很多的数据源,当有数据库请求时,它会对 SQL 语句做必要的改写,然后发往指定的数据源。

    它一般使用标准的 MySQL 通信协议,所以可以很好地支持多语言。由于它是独立部署的,所以也比较方便进行维护升级,比较适合有一定运维能力的大中型团队使用。它的缺陷是所有的 SQL 语句都需要跨两次网络:从应用到代理层和从代理层到数据源,所以在性能上会有一些损耗。

NAME

这些中间件,对你而言,可能并不陌生,但是我想让你注意到是, 在使用任何中间件的时候一定要保证对于中间件有足够深入的了解,否则一旦出了问题没法快速地解决就悲剧了。

我之前的一个项目中,一直使用自研的一个组件来实现分库分表,后来发现这套组件有一定几率会产生对数据库多余的连接,于是团队讨论后决定替换成 Sharding-JDBC。原本以为是一次简单的组件切换,结果上线后发现两个问题:

  • 一是因为使用姿势不对,会偶发地出现分库分表不生效导致扫描所有库表的情况,
  • 二是偶发地出现查询延时达到秒级别。

由于缺少对于 Sharding-JDBC 足够的了解,这两个问题我们都没有很快解决,后来不得已只能切回原来的组件,在找到问题之后再进行切换。

课程小结

本节课,我带你了解了查询量增加时,我们如何通过主从分离和一主多从部署抵抗增加的数据库流量的,你除了掌握主从复制的技术之外,还需要 了解主从分离会带来什么问题以及它们的解决办法 。这里我想让你明确的要点主要有:

  1. 主从读写分离以及部署一主多从可以解决突发的数据库读流量,是一种数据库 横向扩展 的方法;
  2. 读写分离后,主从的延迟是一个关键的监控指标,可能会造成写入数据之后立刻读的时候读取不到的情况;
  3. 业界有很多的方案可以屏蔽主从分离之后数据库访问的细节,让开发人员像是访问单一数据库一样,包括有像 TDDL、Sharding-JDBC 这样的嵌入应用内部的方案,也有像 Mycat 这样的独立部署的代理方案。

其实,我们可以把主从复制引申为存储节点之间互相复制存储数据的技术 ,它可以实现数据的冗余,以达到备份和提升横向扩展能力的作用。在使用主从复制这个技术点时,你一般会考虑两个问题:

  1. 主从的一致性和写入性能的权衡

    如果你要保证所有从节点都写入成功,那么写入性能一定会受影响;

    如果你只写入主节点就返回成功,那么从节点就有可能出现数据同步失败的情况,从而造成主从不一致, 而在互联网的项目中,我们一般会优先考虑性能而不是数据的强一致性。

  2. 主从的延迟问题

    很多诡异的读取不到数据的问题都可能会和它有关,如果你遇到这类问题不妨先看看主从延迟的数据。

我们采用的很多组件都会使用到这个技术,比如,

  • Redis 也是通过主从复制实现读写分离;
  • Elasticsearch 中存储的索引分片也可以被复制到多个节点中;
  • 写入到 HDFS 中文件也会被复制到多个 DataNode 中。

只是不同的组件对于复制的一致性、延迟要求不同,采用的方案也不同。 但是这种设计的思想是通用的,是你需要了解的,这样你在学习其他存储组件的时候就能够触类旁通了。

4.8 - CH09-数据库-分库分表

前一节课,我们学习了在高并发下数据库的一种优化方案:读写分离,它就是依靠主从复制的技术使得数据库实现了数据复制为多份,增强了抵抗 大量并发读请求的能力,提升了数据库的查询性能的同时,也提升了数据的安全性,当某一个数据库节点,无论是主库还是从库发生故障时,我们还有其他的节点中存储着全量的数据,保证数据不会丢失。此时,你的电商系统的架构图变成了下面这样:

NAME

这时,公司 CEO 突然传来一个好消息,运营推广持续带来了流量,你所设计的电商系统的订单量突破了五千万,订单数据都是单表存储的,你的压力倍增,因为无论是数据库的查询还是写入性能都在下降,数据库的磁盘空间也在报警。所以,你主动分析现阶段自己需要考虑的问题,并寻求高效的解决方式,以便系统能正常运转下去。你考虑的问题主要有以下几点:

  1. 系统正在持续不断地的发展

    注册的用户越来越多,产生的订单越来越多,数据库中存储的数据也越来越多,单个表的数据量超过了千万甚至到了亿级别。这时即使你使用了索引,索引占用的空间也随着数据量的增长而增大,数据库就无法缓存全量的索引信息,那么就需要从磁盘上读取索引数据,就会影响到查询的性能了。 那么这时你要如何提升查询性能呢?

  2. 数据量的增加也占据了磁盘的空间,数据库在备份和恢复的时间变长, 你如何让数据库系统支持如此大的数据量呢?

  3. 不同模块的数据,比如用户数据和用户关系数据,全都存储在一个主库中,一旦主库发生故障,所有的模块儿都会受到影响, 那么如何做到不同模块的故障隔离呢?

  4. 你已经知道了,在 4 核 8G 的云服务器上对 MySQL5.7 做 Benchmark,大概可以支撑 500TPS 和 10000QPS,你可以看到数据库对于写入性能要弱于数据查询的能力,那么随着系统写入请求量的增长, 数据库系统如何来处理更高的并发写入请求呢?

这些问题你可以归纳成,数据库的写入请求量大造成的性能和可用性方面的问题 ,要解决这些问题,你所采取的措施就是 对数据进行分片 ,对数据进行分片,可以很好地分摊数据库的读写压力,也可以突破单机的存储瓶颈,而常见的一种方式是对数据库做 分库分表

分库分表是一个很常见的技术方案,你应该有所了解。那你会说了:「既然这个技术很普遍,而我又有所了解,那你为什么还要提及这个话题呢?」因为以我过往的经验来看,不少人会在分库分表这里踩坑,主要体现在:

  1. 对如何使用正确的分库分表方式一知半解,没有明白使用场景和方法。比如,一些同学会在查询时不使用分区键;
  2. 分库分表引入了一些问题后,没有找到合适的解决方案。比如,会在查询时使用大量连表查询等等。

本节课,我就带你解决这两个问题,从常人容易踩坑的地方,跳出来。

如何对数据库做垂直拆分

分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均的分配到多个数据库节点或者多个表中。

不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效的提升数据查询的性能 。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。

比如,我之前做过一个直播项目,在这个项目中,需要存储用户在直播间中发的消息以及直播间中的系统消息,你知道这些消息量极大,有些比较火的直播间有上万条留言是很常见的事儿,日积月累下来就积攒了几亿的数据,查询的性能和存储空间都扛不住了。没办法,就只能加班加点重构,启动多个数据库来分摊写入压力和容量的压力,也需要将原来单库的数据迁移到新启动的数据库节点上,好在最后成功完成分库分表和数据迁移校验工作,不过也着实花费了不少的时间和精力。

数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分。这两种方式,在我看来,掌握拆分方式是关键,理解拆分原理是内核。所以你在学习时,最好可以结合自身业务来思考。

垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的 表拆分到多个不同的数据库中

垂直拆分的原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。举个形象的例子就是在整理衣服的时候,将羽绒服、毛衣、T 恤分别放在不同的格子里。这样可以解决我在开篇提到的第三个问题:把不同的业务的数据分拆到不同的数据库节点上,这样一旦数据库发生故障时只会影响到某一个模块的功能,不会影响到整体功能,从而实现了数据层面的故障隔离。

我还是以微博系统为例来给你说明一下。

在微博系统中有和用户相关的表,有和内容相关的表,有和关系相关的表,这些表都存储在主库中。在拆分后,我们期望用户相关的表分拆到用户库中,内容相关的表分拆到内容库中,关系相关的表分拆到关系库中。

NAME

对数据库进行垂直拆分是一种偏常规的方式,这种方式其实你会比较常用,不过拆分之后,虽然可以暂时缓解存储容量的瓶颈,但并不是万事大吉,因为数据库垂直拆分后依然不能解决某一个业务模块的数据大量膨胀的问题,一旦你的系统遭遇某一个业务库的数据量暴增,在这个情况下,你还需要继续寻找可以弥补的方式。

比如微博关系量早已经过了千亿,单一的数据库或者数据表已经远远不能满足存储和查询的需求了, 这个时候,你需要将数据拆分到多个数据库和数据表中,也就是对数据库和数据表做水平拆分了

如何对数据库做水平拆分

和垂直拆分的关注点不同:

  • 垂直拆分的关注点在于 业务相关性
  • 水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在 数据的特点

拆分的规则有下面这两种:

  1. 按照某一个字段的 哈希值 做拆分

    这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库,64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值;对 64 取余,就得到了分表后的索引值。

NAME
  1. 另一种比较常用的是按照某一个字段的 区间 来拆分,比较常用的是时间字段。

    你知道在内容表里面有「创建时间」的字段,而我们也是按照时间来查看一个人发布的内容。我们可能会要看昨天的内容,也可能会看一个月前发布的内容,这时就可以按照创建时间的区间来分库分表,比如说可以把一个月的数据放入一张表中,这样在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。

    一般来说,列表数据可以使用这种拆分方式,比如一个人一段时间的订单,一段时间发布的内容。但是这种方式可能会存在明显的热点,这很好理解嘛,你当然会更关注最近我买了什么,发了什么,所以查询的 QPS 也会更多一些,对性能有一定的影响。另外,使用这种拆分规则后,数据表要提前建立好,否则如果时间到了 2020 年元旦,DBA(Database Administrator,数据库管理员)却忘记了建表,那么 2020 年的数据就没有库表可写了,就会发生故障了。

NAME

数据库在分库分表之后,数据的访问方式也有了极大的改变,原先只需要根据查询条件到从库中查询数据即可,现在则需要先确认数据在哪一个库表中,再到那个库表中查询数据 。这种复杂度也可以通过数据库中间件来解决,我们在上一节中已经有所讲解,这里就不再赘述了,不过,我想再次强调的是你需要对所使用数据库中间件的原理有足够的了解和足够强的运维上的把控能力。

不过,你要知道的是,分库分表虽然能够解决数据库扩展性的问题,但是它也给我们的使用带来了一些问题。

解决分库分表引入的问题

分库分表引入的一个最大的问题就是 引入了分库分表键,也叫做分区键, 也就是我们对数据库做分库分表所依据的字段。

从分库分表规则中你可以看到,无论是哈希拆分还是区间段的拆分,我们首先都需要选取一个数据库字段,这带来一个问题是:我们之后所有的查询都需要带上这个字段,才能找到数据所在的库和表,否则就只能向所有的数据库和数据表发送查询命令。如果像上面说的要拆分成 16 个库和 64 张表,那么一次数据的查询会变成 16*64=1024 次查询,查询的性能肯定是极差的。

当然,方法总比问题多, 针对这个问题,我们也会有一些相应的解决思路。比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户时,你可以按照昵称作为分区键再做一次拆分,但是这样会极大的增加存储成本,如果以后我们还需要按照注册时间来查询时要怎么办呢,再做一次拆分吗?

所以最合适的思路是 你要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据,这个表也可以是分库分表的,也需要占用一定的存储空间,但是因为表中只有两个字段,所以相比重新做一次拆分还是会节省不少的空间的。

分库分表引入的另外一个问题是一些数据库的特性在实现时可能变得很困难。 比如说多表的 join 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了,不过好在我们对于 join 的需求不高,即使有也一般是把两个表的数据取出后在业务代码里面做筛选,复杂是有一些,不过是可以实现的。再比如说在未分库分表之前查询数据总数时只需要在 SQL 中执行 count() 即可,现在数据被分散到多个库表中,我们可能要考虑其他的方案,比方说将计数的数据单独存储在一张表中或者记录在 Redis 里面。

当然,虽然分库分表会对我们使用数据库带来一些不便,但是相比它所带来的扩展性和性能方面的提升,我们还是需要做的,因为,经历过分库分表后的系统,才能够突破单机的容量和请求量的瓶颈 ,就比如说,我在开篇提到的我们的电商系统,它正是经历了分库分表,才会解决订单表数据量过大带来的性能衰减和容量瓶颈。

课程小结

总的来说,在面对数据库容量瓶颈和写并发量大的问题时,你可以采用垂直拆分和水平拆分来解决,不过你要注意,这两种方式虽然能够解决问题,但是也会引入诸如查询数据必须带上分区键,列表总数需要单独冗余存储等问题。

而且,你需要了解的是在实现分库分表过程中,数据从单库单表迁移多库多表是一件即繁杂又容易出错的事情,而且如果我们初期没有规划得当,后面要继续增加数据库数或者表数时,我们还要经历这个迁移的过程。所以,从我的经验出发,对于分库分表的原则主要有以下几点:

  1. 如果在性能上没有瓶颈点那么就尽量不做分库分表;
  2. 如果要做,就尽量一次到位,比如说 16 库 64 表就基本能够满足为了几年内你的业务的需求。
  3. 很多的 NoSQL 数据库,例如 Hbase,MongoDB 都提供 auto sharding 的特性,如果你的团队内部对于这些组件比较熟悉,有较强的运维能力,那么也可以考虑使用这些 NoSQL 数据库替代传统的关系型数据库。

其实,在我看来,有很多人并没有真正从根本上搞懂为什么要拆分,拆分后会带来哪些问题,只是一味地学习大厂现有的拆分方法,从而导致问题频出。 所以,你在使用一个方案解决一个问题的时候一定要弄清楚原理,搞清楚这个方案会带来什么问题,要如何来解决,要知其然也知其所以然,这样才能在解决问题的同时避免踩坑。

4.9 - CH10-数据库-发号器

在前面两节课程中,我带你了解了分布式存储两个核心问题:数据冗余和数据分片,以及在传统关系型数据库中是如何解决的。

当我们面临高并发的查询数据请求时,可以使用主从读写分离的方式,部署多个从库分摊读压力;

当存储的数据量达到瓶颈时,我们可以将数据分片存储在多个节点上,降低单个存储节点的存储压力,此时我们的架构变成了下面这个样子:

NAME

你可以看到,我们通过分库分表和主从读写分离的方式解决了数据库的扩展性问题,但是在 09 讲我也提到过,数据库在分库分表之后,我们在使用数据库时存在的许多限制,比方说查询的时候必须带着分区键;一些聚合类的查询(像是 count())性能较差,需要考虑使用计数器等其它的解决方案,其实分库分表还有一个问题我在 09 中没有提到,就是 主键的全局唯一性的问题 。本节课,我将带你一起来了解,在分库分表后如何生成全局唯一的数据库主键。

不过,在探究这个问题之前,你需要对「使用什么字段作为主键」这个问题有所了解,这样才能为我们后续探究如何生成全局唯一的主键做好铺垫。

数据库的主键要如何选择?

数据库中的每一条记录都需要有一个唯一的标识,依据数据库的第二范式,数据库中每一个表中都需要有一个唯一的主键,其他数据元素和主键一一对应。

那么关于主键的选择就成为一个关键点了, 一般来讲,你有两种选择方式:

  1. 使用业务字段作为主键,比如说对于用户表来说,可以使用手机号,email 或者身份证号作为主键。
  2. 使用生成的唯一 ID 作为主键。

不过对于大部分场景来说,第一种选择并不适用,比如像评论表你就很难找到一个业务字段作为主键,因为在评论表中,你很难找到一个字段唯一标识一条评论。而对于用户表来说,我们需要考虑的是作为主键的业务字段是否能够唯一标识一个人,一个人可以有多个 email 和手机号,一旦出现变更 email 或者手机号的情况,就需要变更所有引用的外键信息,所以使用 email 或者手机作为主键是不合适的。

身份证号码确实是用户的唯一标识,但是由于它的隐私属性,并不是一个用户系统的必须属性,你想想,你的系统如果没有要求做实名认证,那么肯定不会要求用户填写身份证号码的。并且已有的身份证号码是会变更的,比如在 1999 年时身份证号码就从 15 位变更为 18 位,但是主键一旦变更,以这个主键为外键的表也都要随之变更,这个工作量是巨大的。

因此,我更倾向于使用生成的 ID 作为数据库的主键。 不单单是因为它的唯一性,更是因为一旦生成就不会变更,可以随意引用。

在单库单表的场景下,我们可以使用数据库的自增字段作为 ID,因为这样最简单,对于开发人员来说也是透明的。但是当数据库分库分表后,使用自增字段就无法保证 ID 的全局唯一性了。

想象一下,当我们分库分表之后,同一个逻辑表的数据被分布到多个库中,这时如果使用数据库自增字段作为主键,那么只能保证在这个库中是唯一的,无法保证全局的唯一性。那么假如你来设计用户系统的时候,使用自增 ID 作为用户 ID,就可能出现两个用户有两个相同的 ID,这是不可接受的,那么你要怎么做呢?我建议你搭建发号器服务来生成全局唯一的 ID。

基于 Snowflake 算法搭建发号器

从我历年所经历的项目中,我主要使用的是 变种的 Snowflake 算法来生成业务需要的 ID 的 ,本讲的重点,也是运用它去解决 ID 全局唯一性的问题。搞懂这个算法,知道它是怎么实现的,就足够你应用它来设计一套分布式发号器了,不过你可能会说了:「那你提全局唯一性,怎么不提 UUID 呢?」

没错,UUID(Universally Unique Identifier,通用唯一标识码)不依赖于任何第三方系统,所以在性能和可用性上都比较好,我一般会使用它生成 Request ID 来标记单次请求, 但是如果用它来作为数据库主键,它会存在以下几点问题。

首先,生成的 ID 做好具有单调递增性,也就是有序的,而 UUID 不具备这个特点。为什么 ID 要是有序的呢? 因为在系统设计时,ID 有可能成为排序的字段。 我给你举个例子。

比如,你要实现一套评论的系统时,你一般会设计两个表,一张评论表,存储评论的详细信息,其中有 ID 字段,有评论的内容,还有评论人 ID,被评论内容的 ID 等等,以 ID 字段作为分区键;另一个是评论列表,存储着内容 ID 和评论 ID 的对应关系,以内容 ID 为分区键。

我们在获取内容的评论列表时,需要按照时间序倒序排列,因为 ID 是时间上有序的,所以我们就可以按照评论 ID 的倒序排列。而如果评论 ID 不是在时间上有序的话,我们就需要在评论列表中再存储一个多余的创建时间的列用作排序,假设内容 ID、评论 ID 和时间都是使用 8 字节存储,我们就要多出 50% 的存储空间存储时间字段,造成了存储空间上的浪费。

另一个原因在于 ID 有序也会提升数据的写入性能。

我们知道 MySQL InnoDB 存储引擎使用 B+ 树 存储索引数据,而主键也是一种索引。索引数据在 B+ 树 中是有序排列的,就像下面这张图一样,图中 2,10,26 都是记录的 ID,也是索引数据。

NAME

这时,当插入的下一条记录的 ID 是递增的时候,比如插入 30 时,数据库只需要把它追加到后面就好了。但是如果插入的数据是无序的,比如 ID 是 13,那么数据库就要查找 13 应该插入的位置,再挪动 13 后面的数据,这就造成了多余的数据移动的开销。

NAME

我们知道机械磁盘在完成随机的写时,需要先做 「寻道」找到要写入的位置,也就是让磁头找到对应的磁道,这个过程是非常耗时的。而顺序写就不需要寻道,会大大提升索引的写入性能

UUID 不能作为 ID 的另一个原因是它不具备业务含义, 其实现实世界中使用的 ID 中都包含有一些有意义的数据,这些数据会出现在 ID 的固定的位置上。比如说我们使用的身份证的前六位是地区编号;7~14 位是身份证持有人的生日;不同城市电话号码的区号是不同的;你从手机号码的的前三位就可以看出这个手机号隶属于哪一个运营商。而如果生成的 ID 可以被反解,那么从反解出来的信息中我们可以对 ID 来做验证,我们可以从中知道这个 ID 的生成时间,从哪个机房的发号器中生成的,为哪个业务服务的,对于问题的排查有一定的帮助。

最后,UUID 是由 32 个 16 进制数字组成的字符串,如果作为数据库主键使用比较耗费空间。

你能看到,UUID 方案有很大的局限性,也是我不建议你用它的原因,而 twitter 提出的 Snowflake 算法完全可以弥补 UUID 存在的不足,因为它不仅算法简单易实现,也满足 ID 所需要的全局唯一性,单调递增性,还包含一定的业务上的意义。

Snowflake 的核心思想是将 64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。它的标准算法是这样的:

NAME

从上面这张图中我们可以看到,41 位的时间戳大概可以支撑 pow(2,41)/1000/60/60/24/365 年,约等于 69 年,对于一个系统是足够了。

如果你的系统部署在多个机房,那么 10 位的机器 ID 可以继续划分为 2~3 位的 IDC 标示(可以支撑 4 个或者 8 个 IDC 机房)和 7~8 位的机器 ID(支持 128-256 台机器);12 位的序列号代表着每个节点每毫秒最多可以生成 4096 的 ID。

不同公司也会依据自身业务的特点对 Snowflake 算法做一些改造,比如说减少序列号的位数增加机器 ID 的位数以支持单 IDC 更多的机器,也可以在其中加入业务 ID 字段来区分不同的业务。 比方说我现在使用的发号器的组成规则就是: 1 位兼容位恒为 0 + 41 位时间信息 + 6 位 IDC 信息(支持 64 个 IDC)+ 6 位业务信息(支持 64 个业务)+ 10 位自增信息(每毫秒支持 1024 个号)

我选择这个组成规则,主要是因为我在单机房只部署一个发号器的节点,并且使用 KeepAlive 保证可用性。业务信息指的是项目中哪个业务模块使用,比如用户模块生成的 ID,内容模块生成的 ID,把它加入进来,一是希望不同业务发出来的 ID 可以不同,二是因为在出现问题时可以反解 ID,知道是哪一个业务发出来的 ID。

那么了解了 Snowflake 算法的原理之后,我们如何把它工程化,来为业务生成全局唯一的 ID 呢? 一般来说我们会有两种算法的实现方式:

一种是嵌入到业务代码里,也就是分布在业务服务器中。 这种方案的好处是业务代码在使用的时候不需要跨网络调用,性能上会好一些,但是就需要更多的机器 ID 位数来支持更多的业务服务器。另外,由于业务服务器的数量很多,我们很难保证机器 ID 的唯一性,所以就需要引入 ZooKeeper 等分布式一致性组件来保证每次机器重启时都能获得唯一的机器 ID。

另外一个部署方式是作为独立的服务部署,这也就是我们常说的发号器服务。 业务在使用发号器的时候就需要多一次的网络调用,但是内网的调用对于性能的损耗有限,却可以减少机器 ID 的位数,如果发号器以主备方式部署,同时运行的只有一个发号器,那么机器 ID 可以省略,这样可以留更多的位数给最后的自增信息位。即使需要机器 ID,因为发号器部署实例数有限,那么就可以把机器 ID 写在发号器的配置文件里,这样即可以保证机器 ID 唯一性,也无需引入第三方组件了。 微博和美图都是使用独立服务的方式来部署发号器的,性能上单实例单 CPU 可以达到两万每秒。

Snowflake 算法设计的非常简单且巧妙,性能上也足够高效,同时也能够生成 具有全局唯一性、单调递增性和有业务含义的 ID ,但是它也有一些缺点,其中最大的缺点就是它依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止。

另外,如果请求发号器的 QPS 不高,比如说发号器每毫秒只发一个 ID,就会造成生成 ID 的末位永远是 1,那么在分库分表时如果使用 ID 作为分区键就会造成库表分配的不均匀。 这一点,也是我在实际项目中踩过的坑,而解决办法主要有两个:

  1. 时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多发出几个号,避免出现分库分表时数据分配不均。
  2. 生成的序列号的起始号可以做一下随机,这一秒是 21,下一秒是 30,这样就会尽量的均衡了。

我在开头提到,自己的实际项目中采用的是变种的 Snowflake 算法,也就是说对 Snowflake 算法进行了一定的改造,从上面的内容中你可以看出,这些改造:

  • 一是要让算法中的 ID 生成规则符合自己业务的特点;
  • 二是为了解决诸如时间回拨等问题。

其实,大厂除了采取 Snowflake 算法之外,还会选用一些其他的方案,比如滴滴和美团都有提出基于数据库生成 ID 的方案。这些方法根植于公司的业务,同样能解决分布式环境下 ID 全局唯一性的问题。对你而言,可以多角度了解不同的方法,这样能够寻找到更适合自己业务目前场景的解决方案,不过我想说的是, 方案不在多,而在精,方案没有最好,只有最适合,真正弄懂方法背后的原理,并将它落地,才是你最佳的选择。

课程小结

本节课,我结合自己的项目经历带你了解了如何使用 Snowflake 算法解决分库分表后数据库 ID 的全局唯一的问题,在这个问题中,又延伸性地带你了解了生成的 ID 需要满足单调递增性,以及要具有一定业务含义的特性。当然,我们重点的内容是讲解如何将 Snowflake 算法落地,以及在落地过程中遇到了哪些坑,带你去解决它。

Snowflake 的算法并不复杂,你在使用的时候可以不考虑独立部署的问题,先想清楚按照自身的业务场景,需要如何设计 Snowflake 算法中的每一部分占的二进制位数。比如你的业务会部署几个 IDC,应用服务器要部署多少台机器,每秒钟发号个数的要求是多少等等,然后在业务代码中实现一个简单的版本先使用,等到应用服务器数量达到一定规模,再考虑独立部署的问题就可以了。这样可以避免多维护一套发号器服务,减少了运维上的复杂度。

4.10 - CH11-数据库-NoSQL

前几节课,我带你了解了在你的垂直电商项目中,如何将传统的关系型数据库改造成分布式存储服务,以抵抗高并发和大流量的冲击。

对于存储服务来说,我们一般会从两个方面对它做改造:

  1. 提升它的读写性能,尤其是读性能,因为我们面对的多是一些读多写少的产品。比方说,你离不开的微信朋友圈、微博和淘宝,都是查询 QPS 远远大于写入 QPS。
  2. 增强它在存储上的扩展能力,从而应对大数据量的存储需求。

我之前带你学习的读写分离和分库分表就是从这两方面出发,改造传统的关系型数据库的,但仍有一些问题无法解决。

比如,在微博项目中关系的数据量达到了千亿,那么即使分隔成 1024 个库表,每张表的数据量也达到了亿级别,并且关系的数据量还在以极快的速度增加,即使你分隔成再多的库表,数据量也会很快增加到瓶颈。这个问题用传统数据库很难根本解决,因为它在扩展性方面是很弱的,这时,就可以利用 NoSQL,因为它有着天生分布式的能力,能够提供优秀的读写性能,可以很好地补充传统关系型数据库的短板。那么它是如何做到的呢?

这节课,我就还是以你的垂直电商系统为例,带你掌握如何用 NoSQL 数据库和关系型数据库互补 ,共同承担高并发和大流量的冲击。

首先,我们先来了解一下 NoSQL 数据库。

NoSQL,No SQL?

NoSQL 想必你很熟悉,它指的是不同于传统的关系型数据库的其他数据库系统的统称,它不使用 SQL 作为查询语言,提供优秀的横向扩展能力和读写性能,非常契合互联网项目高并发大数据的特点。所以一些大厂,比如小米、微博、陌陌都很倾向使用它来作为高并发大容量的数据存储服务。

NoSQL 数据库发展到现在,十几年间,出现了多种类型,我来给你举几个例子:

  • Redis、LevelDB 这样的 KV 存储。这类存储相比于传统的数据库的优势是极高的读写性能,一般对性能有比较高的要求的场景会使用。
  • Hbase、Cassandra 这样的 列式存储数据库。这种数据库的特点是数据不像传统数据库以行为单位来存储,而是以列来存储,适用于一些离线数据统计的场景。
  • 像 MongoDB、CouchDB 这样的文档型数据库。这种数据库的特点是 Schema Free(模式自由),数据表中的字段可以任意扩展,比如说电商系统中的商品有非常多的字段,并且不同品类的商品的字段也都不尽相同,使用关系型数据库就需要不断增加字段支持,而用文档型数据库就简单很多了。

在 NoSQL 数据库刚刚被应用时,它被认为是可以替代关系型数据库的银弹,在我看来,也许因为以下几个方面的原因:

  • 弥补了传统数据库在性能方面的不足;
  • 数据库变更方便,不需要更改原先的数据结构;
  • 适合互联网项目常见的大数据量的场景;

不过,这种看法是个误区,因为慢慢地我们发现在业务开发的场景下还是需要利用 SQL 语句的强大的查询功能以及传统数据库事务和灵活的索引等功能,NoSQL 只能作为一些场景的补充。

那么接下来,我就带你了解 NoSQL 数据库是如何做到与关系数据库互补的。 了解这部分内容,你可以在实际项目中更好地使用 NoSQL 数据库补充传统数据库的不足。

首先,我们来关注一下数据库的写入性能。

使用 NoSQL 提升写入性能

数据库系统大多使用的是传统的机械磁盘,对于机械磁盘的访问方式有两种:

  • 一种是随机 IO

    随机 IO 就需要花费时间做昂贵的磁盘寻道,一般来说,它的读写效率要比顺序 IO 小两到三个数量级,所以我们想要提升写入的性能就要尽量减少随机 IO

  • 另一种是顺序 IO

以 MySQL 的 InnoDB 存储引擎来说,更新 binlog、redolog、undolog 都是在做顺序 IO,而更新 datafile 和索引文件则是在做随机 IO,而为了减少随机 IO 的发生,关系数据库已经做了很多的优化,比如说写入时先写入内存,然后批量刷新到磁盘上,但是随机 IO 还是会发生。

索引在 InnoDB 引擎中是以 B+ 树(上一节课提到了 B+ 树,你可以回顾一下)方式来组织的,而 MySQL 主键是聚簇索引(一种索引类型,数据与索引数据放在一起),既然数据和索引数据放在一起,那么在数据插入或者更新的时候,我们需要找到要插入的位置,再把数据写到特定的位置上,这就产生了随机的 IO。而且一旦发生了页分裂,就不可避免会做数据的移动,也会极大地损耗写入性能。

NoSQL 数据库是怎么解决这个问题的呢?

它们有多种的解决方式,这里我给你讲一种最常见的方案,就是很多 NoSQL 数据库都在使用的 基于 LSM 树的存储引擎, 这种算法使用最多,所以在这里着重剖析一下。

LSM 树(Log-Structured Merge Tree)牺牲了一定的读性能来换取写入数据的高性能,Hbase、Cassandra、LevelDB 都是用这种算法作为存储的引擎。

它的思想很简单,数据首先会写入到一个叫做 MemTable 的内存结构中,在 MemTable 中数据是按照写入的 Key 来排序的。为了防止 MemTable 里面的数据因为机器掉电或者重启而丢失,一般会通过写 Write Ahead Log 的方式将数据备份在磁盘上。

MemTable 在累积到一定规模时,它会被刷新生成一个新的文件,我们把这个文件叫做 SSTable(Sorted String Table)。当 SSTable 达到一定数量时,我们会将这些 SSTable 合并,减少文件的数量,因为 SSTable 都是有序的,所以合并的速度也很快。

当从 LSM 树里面读数据时,我们首先从 MemTable 中查找数据,如果数据没有找到,再从 SSTable 中查找数据。因为存储的数据都是有序的,所以查找的效率是很高的,只是因为数据被拆分成多个 SSTable,所以读取的效率会低于 B+ 树索引。

NAME

和 LSM 树类似的算法有很多,比如说 TokuDB 使用的名为 Fractal tree 的索引结构,它们的核心思想就是将随机 IO 变成顺序的 IO,从而提升写入的性能。

在后面的缓存篇中,我也将给你着重介绍我们是如何使用 KV 型 NoSQL 存储来提升读性能的。所以你看,NoSQL 数据库补充关系型数据库的第一种方式就是提升读写性能。

场景补充

除了可以提升性能之外,NoSQL 数据库还可以在某些场景下作为传统关系型数据库的补充,来看一个具体的例子。

假设某一天,CEO 找到你并且告诉你,他正在为你的垂直电商项目规划搜索的功能,需要支持按照商品的名称模糊搜索到对应的商品,希望你尽快调研出解决方案。

一开始,你认为这非常的简单,不就是在数据库里面执行一条类似:select * from product where name like ‘%***%’ 的语句吗?可是在实际执行的过程中,却发现了问题。

你发现这类语句并不是都能使用到索引,只有后模糊匹配的语句才能使用索引。比如语句 select * from product where name like ‘% 电冰箱’ 就没有使用到字段 name 上的索引,而 select * from product where name like ‘索尼 %’ 就使用了 name 上的索引。而一旦没有使用索引就会扫描全表的数据,在性能上是无法接受的。

于是你在谷歌上搜索了一下解决方案,发现大家都在使用开源组件 Elasticsearch 来支持搜索的请求,它本身是基于“倒排索引”来实现的, 那么什么是倒排索引呢?

倒排索引是指将记录中的某些列做分词,然后形成的分词与记录 ID 之间的映射关系。比如说,你的垂直电商项目里面有以下记录:

NAME

那么,我们将商品名称做简单的分词,然后建立起分词和商品 ID 的对应关系,就像下面展示的这样:

NAME

这样,如果用户搜索电冰箱,就可以给他展示商品 ID 为 1 和 3 的两件商品了。

而 Elasticsearch 作为一种常见的 NoSQL 数据库, 就以倒排索引作为核心技术原理,为你提供了分布式的全文搜索服务,这在传统的关系型数据库中使用 SQL 语句是很难实现的。 所以你看,NoSQL 可以在某些业务场景下代替传统数据库提供数据存储服务。

提升扩展性

另外,在扩展性方面,很多 NoSQL 数据库也有着先天的优势。还是以你的垂直电商系统为例,你已经为你的电商系统增加了评论系统,开始你的评估比较乐观,觉得电商系统的评论量级不会增长很快,所以就为它分了 8 个库,每个库拆分成 16 张表。

但是评论系统上线之后,存储量级增长的异常迅猛,你不得不将数据库拆分成更多的库表,而数据也要重新迁移到新的库表中,过程非常痛苦,而且数据迁移的过程也非常容易出错。

这时,你考虑是否可以考虑使用 NoSQL 数据库来彻底解决扩展性的问题,经过调研你发现它们在设计之初就考虑到了分布式和大数据存储的场景, 比如像 MongoDB 就有三个扩展性方面的特性。

  • 其一是 Replica,也叫做副本集,你可以理解为主从分离,也就是通过将数据拷贝成多份来保证当主挂掉后数据不会丢失。同时呢,Replica 还可以分担读请求。Replica 中有主节点来承担写请求,并且把对数据变动记录到 oplog 里(类似于 binlog);从节点接收到 oplog 后就会修改自身的数据以保持和主节点的一致。一旦主节点挂掉,MongoDB 会从从节点中选取一个节点成为主节点,可以继续提供写数据服务。
  • 其二是 Shard,也叫做分片,你可以理解为分库分表,即将数据按照某种规则拆分成多份,存储在不同的机器上。MongoDB 的 Sharding 特性一般需要三个角色来支持,一个是 Shard Server,它是实际存储数据的节点,是一个独立的 Mongod 进程;二是 Config Server,也是一组 Mongod 进程,主要存储一些元信息,比如说哪些分片存储了哪些数据等;最后是 Route Server,它不实际存储数据,仅仅作为路由使用,它从 Config Server 中获取元信息后,将请求路由到正确的 Shard Server 中。
NAME
  • 其三是负载均衡,就是当 MongoDB 发现 Shard 之间数据分布不均匀,会启动 Balancer 进程对数据做重新的分配,最终让不同 Shard Server 的数据可以尽量的均衡。当我们的 Shard Server 存储空间不足需要扩容时,数据会自动被移动到新的 Shard Server 上,减少了数据迁移和验证的成本。

你可以看到,NoSQL 数据库中内置的扩展性方面的特性可以让我们不再需要对数据库做分库分表和主从分离,也是对传统数据库一个良好的补充。

你可能会觉得,NoSQL 已经成熟到可以代替关系型数据库了,但是就目前来看,NoSQL 只能作为传统关系型数据库的补充而存在,弥补关系型数据库在性能、扩展性和某些场景下的不足,所以你在使用或者选择时要结合自身的场景灵活地运用。

课程小结

本节课我带你了解了 NoSQL 数据库在性能、扩展性上的优势,以及它的一些特殊功能特性,主要有以下几点:

  1. 在性能方面,NoSQL 数据库使用一些算法将对磁盘的随机写转换成顺序写,提升了写的性能;
  2. 在某些场景下,比如全文搜索功能,关系型数据库并不能高效地支持,需要 NoSQL 数据库的支持;
  3. 在扩展性方面,NoSQL 数据库天生支持分布式,支持数据冗余和数据分片的特性。

这些都让它成为传统关系型数据库的良好的补充,你需要了解的是, NoSQL 可供选型的种类很多,每一个组件都有各自的特点。你在做选型的时候需要对它的实现原理有比较深入的了解,最好在运维方面对它有一定的熟悉,这样在出现问题时才能及时找到解决方案。 否则,盲目跟从地上了一个新的 NoSQL 数据库,最终可能导致会出了故障无法解决,反而成为整体系统的拖累。

我在之前的项目中曾经使用 Elasticsearch 作为持久存储,支撑社区的 feed 流功能,初期开发的时候确实很爽,你可以针对 feed 中的任何字段做灵活高效地查询,业务功能迭代迅速,代码也简单易懂。可是到了后期流量上来之后,由于缺少对于 Elasticsearch 成熟的运维能力,造成故障频出,尤其到了高峰期就会出现节点不可用的问题,而由于业务上的巨大压力又无法分出人力和精力对 Elasticsearch 深入的学习和了解,最后不得不做大的改造切回熟悉的 MySQL。 所以,对于开源组件的使用,不能只停留在只会 hello world 的阶段,而应该对它有足够的运维上的把控能力。

4.11 - CH12-缓存-基础

通过前面数据库篇的学习,你已经了解了在高并发大流量下,数据库层的演进过程以及库表设计上的考虑点。你的垂直电商系统在完成了对数据库的主从分离和分库分表之后,已经可以支撑十几万 DAU 了,整体系统的架构也变成了下面这样:

NAME

从整体上看,数据库分了主库和从库,数据也被切分到多个数据库节点上。但随着并发的增加,存储数据量的增多,数据库的磁盘 IO 逐渐成了系统的瓶颈,我们需要一种访问更快的组件来降低请求响应时间,提升整体系统性能。这时我们就会使用缓存。 那么什么是缓存,我们又该如何将它的优势最大化呢?

本节课是缓存篇的总纲, 我将从缓存定义、缓存分类和缓存优势劣势三个方面全方位带你掌握缓存的设计思想和理念,再用剩下 4 节课的时间,带你针对性地掌握使用缓存的正确姿势,以便让你在实际工作中能够更好地使用缓存提升整体系统的性能。

什么是缓存

缓存,是一种存储数据的组件,它的作用是让对数据的请求更快地返回。

我们经常会把缓存放在内存中来存储, 所以有人就把内存和缓存画上了等号,这完全是外行人的见解。作为业内人士,你要知道在某些场景下我们可能还会使用 SSD 作为冷数据的缓存。比如说 360 开源的 Pika 就是使用 SSD 存储数据解决 Redis 的容量瓶颈的。

实际上,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存 。那么说到这儿我们就需要知道常见硬件组件的延时情况是什么样的了,这样在做方案的时候可以对延迟有更直观的印象。幸运的是,业内已经有人帮我们总结出这些数据了,我将这些数据整理了一下,你可以看一下。

NAME

从这些数据中,你可以看到,做一次内存寻址大概需要 100ns,而做一次磁盘的查找则需要 10ms。如果我们将做一次内存寻址的时间类比为一个课间,那么做一次磁盘查找相当于度过了大学的一个学期。可见,我们使用内存作为缓存的存储介质相比于以磁盘作为主要存储介质的数据库来说,性能上会提高多个数量级,同时也能够支撑更高的并发量。所以,内存是最常见的一种缓存数据的介质。

缓存作为一种常见的 空间换时间的性能优化手段,在很多地方都有应用,我们先来看几个例子,相信你一定不会陌生。

1. 缓存案例

Linux 内存管理是通过一个叫做 MMU(Memory Management Unit)的硬件,来实现从虚拟地址到物理地址的转换的,但是如果每次转换都要做这么复杂计算的话,无疑会造成性能的损耗,所以我们会借助一个叫做 TLB(Translation Lookaside Buffer)的组件来缓存最近转换过的虚拟地址,和物理地址的映射。TLB 就是一种缓存组件,缓存复杂运算的结果,就好比你做一碗色香味俱全的面条可能比较复杂,那么我们把做好的面条油炸处理一下做成方便面,你做方便面的话就简单多了,也快速多了。这个缓存组件比较底层,这里你只需要了解一下就可以了。

在大部分的笔记本,桌面电脑和服务器上都会有一个或者多个 TLB 组件,在不经意间帮助我们加快地址转换的速度。

再想一下你平时经常刷的抖音。 平台上的短视频实际上是使用内置的网络播放器来完成的。网络播放器接收的是数据流,将数据下载下来之后经过分离音视频流,解码等流程后输出到外设设备上播放。

如果我们在打开一个视频的时候才开始下载数据的话,无疑会增加视频的打开速度(我们叫首播时间),并且播放过程中会有卡顿。所以我们的播放器中通常会设计一些缓存的组件,在未打开视频时缓存一部分视频数据,比如我们打开抖音,服务端可能一次会返回三个视频信息,我们在播放第一个视频的时候,播放器已经帮我们缓存了第二、三个视频的部分数据,这样在看第二个视频的时候就可以给用户 「秒开」 的感觉。

除此之外,我们熟知的 HTTP 协议也是有缓存机制的。当我们第一次请求静态的资源时,比如一张图片,服务端除了返回图片信息,在响应头里面还有一个 Etag 的字段。浏览器会缓存图片信息以及这个字段的值。当下一次再请求这个图片的时候,浏览器发起的请求头里面会有一个 If-None-Match 的字段,并且把缓存的 Etag 的值写进去发给服务端。服务端比对图片信息是否有变化,如果没有,则返回浏览器一个 304 的状态码,浏览器会继续使用缓存的图片信息。通过这种缓存协商的方式,可以减少网络传输的数据大小,从而提升页面展示的性能。

NAME

2. 缓存与缓冲区

讲了这么多缓存案例,想必你对缓存已经有了一个直观并且形象的了解了。除了缓存,我们在日常开发过程中还会经常听见一个相似的名词—— 缓冲区 ,那么,什么是缓冲区呢?缓冲和缓存只有一字之差,它们有什么区别呢?

我们知道,缓存可以提高低速设备的访问速度,或者减少复杂耗时的计算带来的性能问题。理论上说,我们可以通过缓存解决所有关于「慢」的问题,比如从磁盘随机读取数据慢,从数据库查询数据慢,只是不同的场景消耗的存储成本不同。

缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。 缓冲区更像「消息队列篇」中即将提到的消息队列,用以弥补高速设备和低速设备通信时的速度差 。比如,我们将数据写入磁盘时并不是直接刷盘,而是写到一块缓冲区里面,内核会标识这个缓冲区为脏。当经过一定时间或者脏缓冲区比例到达一定阈值时,由单独的线程把脏块刷新到硬盘。这样避免了每次写数据都要刷盘带来的性能问题。

NAME

以上就是缓冲区和缓存的区别,从这个区别来看,上面提到的 TLB 的命名是有问题的,它应该是缓存而不是缓冲区。

现在你已经了解了缓存的含义,那么我们经常使用的缓存都有哪些?我们又该如何使用缓存,将它的优势最大化呢?

缓存分类

在我们日常开发中,常见的缓存主要就是 静态缓存、分布式缓存和热点本地缓存 这三种。

静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。例如,我们在做一些内容管理系统的时候,后台会录入很多的文章,前台在网站上展示文章内容,就像新浪,网易这种门户网站一样。

当然,我们也可以把文章录入到数据库里面,然后前端展示的时候穿透查询数据库来获取数据,但是这样会对数据库造成很大的压力。即使我们使用分布式缓存来挡读请求,但是对于像日均 PV 几十亿的大型门户网站来说,基于成本考虑仍然是不划算的。

**所以我们的解决思路是 ** 每篇文章在录入的时候渲染成静态页面,放置在所有的前端 Nginx 或者 Squid 等 Web 服务器上,这样用户在访问的时候会优先访问 Web 服务器上的静态页面,在对旧的文章执行一定的清理策略后,依然可以保证 99% 以上的缓存命中率。

这种缓存只能针对静态数据来缓存,对于动态请求就无能为力了。那么我们如何针对动态请求做缓存呢? 这时你就需要分布式缓存了。

分布式缓存的大名可谓是如雷贯耳了,我们平时耳熟能详的 Memcached、Redis 就是分布式缓存的典型例子。它们性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色(接下来的课程我会专门针对分布式缓存,带你了解分布式缓存的使用技巧以及高可用的方案,让你能在工作中对分布式缓存运用自如)。

对于静态的资源的缓存你可以选择静态缓存,对于动态的请求你可以选择分布式缓存, 那么什么时候要考虑热点本地缓存呢?

答案是当我们遇到极端的热点数据查询的时候。 热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。

比如某一位明星在微博上有了热点话题,「吃瓜群众」会到他 (她) 的微博首页围观,这就会引发这个用户信息的热点查询。这些查询通常会命中某一个缓存节点或者某一个数据库分区,短时间内会形成极高的热点查询。

那么我们会在代码中使用一些本地缓存方案,如 HashMap,Guava Cache 或者是 Ehcache 等,它们和应用程序部署在同一个进程中,优势是不需要跨网络调度,速度极快,所以可以来阻挡短时间内的热点查询。 来看个例子。

**比方说 ** 你的垂直电商系统的首页有一些推荐的商品,这些商品信息是由编辑在后台录入和变更。你分析编辑录入新的商品或者变更某个商品的信息后,在页面的展示是允许有一些延迟的,比如说 30 秒的延迟,并且首页请求量最大,即使使用分布式缓存也很难抗住,所以你决定使用 Guava Cache 来将所有的推荐商品的信息缓存起来,并且设置每隔 30 秒重新从数据库中加载最新的所有商品。

首先,我们初始化 Guava 的 Loading Cache:

CacheBuilder<String, List<Product>> cacheBuilder = CacheBuilder.newBuilder().maximumSize(maxSize).recordStats(); // 设置缓存最大值
cacheBuilder = cacheBuilder.refreshAfterWrite(30, TimeUnit.Seconds); // 设置刷新间隔
 
LoadingCache<String, List<Product>> cache = cacheBuilder.build(new CacheLoader<String, List<Product>>() {
    @Override
    public List<Product> load(String k) throws Exception {
        return productService.loadAll(); // 获取所有商品
    }
});

这样,你在获取所有商品信息的时候可以调用 Loading Cache 的 get 方法,就可以优先从本地缓存中获取商品信息,如果本地缓存不存在,会使用 CacheLoader 中的逻辑从数据库中加载所有的商品。

由于本地缓存是部署在应用服务器中,而我们应用服务器通常会部署多台,当数据更新时,我们不能确定哪台服务器本地中了缓存,更新或者删除所有服务器的缓存不是一个好的选择,所以我们通常会等待缓存过期。因此,这种缓存的有效期很短,通常为分钟或者秒级别,以避免返回前端脏数据。

缓存的不足

通过了解上面的内容,你不难发现,缓存的主要作用是提升访问速度,从而能够抗住更高的并发。那么,缓存是不是能够解决一切问题?显然不是。事物都是具有两面性的,缓存也不例外,我们要了解它的优势的同时也需要了解它有哪些不足,从而扬长避短,将它的作用发挥到最大。

首先,缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性。 这是因为缓存毕竟会受限于存储介质不可能缓存所有数据,那么当数据有热点属性的时候才能保证一定的缓存命中率。比如说类似微博、朋友圈这种 20% 的内容会占到 80% 的流量。所以,一旦当业务场景读少写多时或者没有明显热点时,比如在搜索的场景下,每个人搜索的词都会不同,没有明显的热点,那么这时缓存的作用就不明显了。

其次,缓存会给整体系统带来复杂度,并且会有数据不一致的风险。 当更新数据库成功,更新缓存失败的场景下,缓存中就会存在脏数据。对于这种场景,我们可以考虑使用较短的过期时间或者手动清理的方式来解决。

再次,之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的。 因此,我们在使用缓存的时候要做数据存储量级的评估,对于可预见的需要消耗极大存储成本的数据,要慎用缓存方案。同时,缓存一定要设置过期时间,这样可以保证缓存中的会是热点数据。

最后,缓存会给运维也带来一定的成本, 运维需要对缓存组件有一定的了解,在排查问题的时候也多了一个组件需要考虑在内。

虽然有这么多的不足,但是缓存对于性能的提升是毋庸置疑的,我们在做架构设计的时候也需要把它考虑在内,只是在做具体方案的时候需要对缓存的设计有更细致的思考,才能最大化的发挥缓存的优势。

课程小结

这节课我带你了解了缓存的定义,常见缓存的分类以及缓存的不足。我想跟你强调的重点有以下几点:

  • 缓存可以有多层,比如上面提到的静态缓存处在负载均衡层,分布式缓存处在应用层和数据库层之间,本地缓存处在应用层。我们需要将请求尽量挡在上层,因为越往下层,对于并发的承受能力越差;
  • 缓存命中率是我们对于缓存最重要的一个监控项,越是热点的数据,缓存的命中率就越高。

你还需要理解的是,缓存不仅仅是一种组件的名字,更是一种设计思想,你可以认为任何能够加速读请求的组件和设计方案都是缓存思想的体现。而这种加速通常是通过两种方式来实现:

  • 使用更快的介质,比方说课程中提到的内存;
  • 缓存复杂运算的结果,比方说前面 TLB 的例子就是缓存地址转换的结果。

那么,当你在实际工作中碰到“慢”的问题时,缓存就是你第一时间需要考虑的。

4.12 - CH13-缓存-读写策略

上节课,我带你了解了缓存的定义、分类以及不足,你现在应该对缓存有了初步的认知。从今天开始,我将带你了解一下使用缓存的正确姿势,比如缓存的读写策略是什么样的,如何做到缓存的高可用以及如何应对缓存穿透。通过了解这些内容,你会对缓存的使用有深刻的认识,这样在实际工作中就可以在缓存使用上游刃有余了。

今天,我们先讲讲缓存的读写策略。你可能觉得缓存的读写很简单,只需要优先读缓存,缓存不命中就从数据库查询,查询到了就回种缓存。实际上,针对不同的业务场景,缓存的读写策略也是不同的。

而我们在选择策略时也需要考虑诸多的因素,比如说,缓存中是否有可能被写入脏数据,策略的读写性能如何,是否存在缓存命中率下降的情况等等。接下来,我就以标准的 缓存 + 数据库 的场景为例,带你剖析经典的缓存读写策略以及它们适用的场景。这样一来,你就可以在日常的工作中根据不同的场景选择不同的读写策略。

Cache Aside(旁路缓存)策略

我们来考虑一种最简单的业务场景,比方说在你的电商系统中有一个用户表,表中只有 ID 和年龄两个字段,缓存中我们以 ID 为 Key 存储用户的年龄信息。那么当我们要把 ID 为 1 的用户的年龄从 19 变更为 20,要如何做呢?

你可能会产生这样的思路: 先更新数据库中 ID 为 1 的记录,再更新缓存中 Key 为 1 的数据。

NAME

这个思路会造成缓存和数据库中的数据不一致。 比如,A 请求将数据库中 ID 为 1 的用户年龄从 19 变更为 20,与此同时,请求 B 也开始更新 ID 为 1 的用户数据,它把数据库中记录的年龄变更为 21,然后变更缓存中的用户年龄为 21。紧接着,A 请求开始更新缓存数据,它会把缓存中的年龄变更为 20。此时,数据库中用户年龄是 21,而缓存中的用户年龄却是 20。

NAME

为什么产生这个问题呢? 因为变更数据库和变更缓存是两个独立的操作,而我们并没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不同造成数据的不一致。

另外,直接更新缓存还存在另外一个问题就是丢失更新。还是以我们的电商系统为例,假如电商系统中的账户表有三个字段:ID、户名和金额,这个时候缓存中存储的就不只是金额信息,而是完整的账户信息了。当更新缓存中账户金额时,你需要从缓存中查询完整的账户数据,把金额变更后再写入到缓存中。

这个过程中也会有并发的问题,比如说原有金额是 20,A 请求从缓存中读到数据,并且把金额加 1,变更成 21,在未写入缓存之前又有请求 B 也读到缓存的数据后把金额也加 1,也变更成 21,两个请求同时把金额写回缓存,这时缓存里面的金额是 21,但是我们实际上预期是金额数加 2,这也是一个比较大的问题。

那我们要如何解决这个问题呢? 其实,我们可以在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

NAME

这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据 以数据库中的数据为准,缓存中的数据是按需加载的 。它可以分为读策略和写策略,

读策略的步骤是:

  • 从缓存中读取数据;
  • 如果缓存命中,则直接返回数据;
  • 如果缓存不命中,则从数据库中查询数据;
  • 查询到数据后,将数据写入到缓存中,并且返回给用户。

写策略的步骤是:

  • 更新数据库中的记录;
  • 删除缓存记录。

你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢? 答案是不行的, 因为这样也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。

假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21,这就造成了缓存和数据库的不一致。

NAME

那么像 Cache Aside 策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上还是有缺陷的。假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存和数据库数据不一致。

NAME

不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入 ,所以在实际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载数据,所以不会出现这种不一致的情况。

Cache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。 比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时 ,会出现因为主从延迟所以读不到用户信息的情况。

而解决这个问题的办法 恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况

Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。 如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

  1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
  2. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。

TIP

笔者感觉上面的一些点感觉很混乱,对缓存命中率有要求,根本原因是写入频繁,加锁单线程更新就能保证了?

这里细说一下: 数据更新:发送更新缓存指令给 缓存组件,由缓存组件对同一个 key 是加锁更新的,它可以进行一个时间窗口的优化很厚再更新

读数据:发现没有缓存? 则发送读缓存指令给缓存,然后自己这边等待缓存数据的更新,直到读到后或则超时返回。

当然了,除了这个策略,在计算机领域还有其他几种经典的缓存策略,它们也有各自适用的使用场景。

Read/Write Through(读穿 / 写穿)策略

这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据 。这就好比你在汇报工作的时候只对你的直接上级汇报,再由你的直接上级汇报给他的上级,你是不能越级汇报的。

Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做 Write Miss(写失效)

一般来说,我们可以选择两种 Write Miss 方式:

  • Write Allocate(按写分配)

    做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;

  • No-write allocate(不按写分配)

    做法是不写入缓存中,而是直接更新到数据库中

在 Write Through 策略中,我们一般选择 No-write allocate 方式,原因是无论采用哪种 Write Miss 方式,我们都需要同步将数据更新到数据库中,而 No-write allocate 方式相比 Write Allocate 还减少了一次缓存的写入,能够提升写入的性能。

Read Through 策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。

下面是 Read Through/Write Through 策略的示意图:

NAME

Read Through/Write Through 策略的特点是 由缓存节点而非用户来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库,或者自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略,比如说在上一节中提到的本地缓存 Guava Cache 中的 Loading Cache 就有 Read Through 策略的影子。

我们看到 Write Through 策略中写数据库是同步的 ,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。那么我们可否异步地更新数据库?这就是我们接下来要提到的 Write Back 策略。

Write Back(写回)策略

这个策略的核心思想是 在写入数据时只写入缓存,并且把缓存块儿标记为 「脏」 的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。

需要注意的是,Write Miss 的情况下,我们采用的是 Write Allocate 的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将 Write back 策略的示意图放在了下面:

NAME

如果使用 Write Back 策略的话,读的策略也有一些变化了:

  1. 我们在读取缓存时如果发现 缓存命中则直接返回缓存数据

  2. 如果缓存不命中则寻找一个可用的缓存块儿

    如果这个缓存块儿是 「脏」的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿

    如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。

NAME

发现了吗? 其实这种策略不能被应用到我们常用的数据库和缓存的场景中,它是计算机体系结构中的设计,比如我们在向磁盘中写数据时采用的就是这种策略。无论是操作系统层面的 Page Cache,还是日志的异步刷盘,亦或是消息队列中消息的异步写入磁盘,大多采用了这种策略。因为这个策略在性能上的优势毋庸置疑,它避免了直接写磁盘造成的随机写问题,毕竟写内存和写磁盘的随机 I/O 的延迟相差了几个数量级呢。

但因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏块儿数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。

TIP

笔者疑问:在读的时候,缓存不命中,还可以去找缓存块?都没有命中了,这里不明白.

计算机体系结构中的策略,它的完整读策略是这样的:如果缓存命中,则直接返回;如果缓存不命中,则重新找一个缓存块儿,如果这个缓存块儿是脏的,那么写入后端存储,并且把后端存储中的数据加载到缓存中;如果不是脏的,那么就把后端存储中的数据加载到缓存,然后标记缓存非脏

所以说这里介绍的,不要看成是普通的一个 key 就对应这个 key 应该有的缓存数据

当然,你依然可以在一些场景下使用这个策略,在使用时,我想给你的落地建议是: 你在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。比如说,你在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘 I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。

课程小结

本节课,我主要带你了解了缓存使用的几种策略,以及每种策略适用的使用场景是怎样的。我想让你掌握的重点是:

  1. Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。
  2. Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;
  3. Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。

而且,你还需要了解,我们今天提到的策略都是标准的使用姿势,在实际开发过程中需要结合实际的业务特点灵活使用甚至加以改造。这些业务特点包括但不仅限于:整体的数据量级情况,访问的读写比例的情况,对于数据的不一致时间的容忍度,对于缓存命中率的要求等等。理论结合实践,具体情况具体分析,你才能得到更好的解决方案。

4.13 - CH14-缓存-高可用

前面几节课,我带你了解了缓存的原理、分类以及常用缓存的使用技巧。我们开始用缓存承担大部分的读压力,从而缓解数据库的查询压力,在提升性能的同时保证系统的稳定性。这时,你的电商系统整体的架构演变成下图的样子:

NAME

我们在 Web 层和数据库层之间增加了缓存层,请求会首先查询缓存,只有当缓存中没有需要的数据时才会查询数据库。

在这里,你需要关注缓存命中率这个指标(缓存命中率 = 命中缓存的请求数 / 总请求数)。一般来说,在你的电商系统中,核心缓存的命中率需要维持在 99% 甚至是 99.9%,哪怕下降 1%,系统都会遭受毁灭性的打击。

这绝不是危言耸听,我们来计算一下。假设系统的 QPS 是 10000/s,每次调用会访问 10 次缓存或者数据库中的数据,那么当缓存命中率仅仅减少 1%,数据库每秒就会增加 10000 * 10 * 1% = 1000 次请求。而一般来说我们单个 MySQL 节点的读请求量峰值就在 1500/s 左右,增加的这 1000 次请求很可能会给数据库造成极大的冲击。

命中率仅仅下降 1% 造成的影响就如此可怕,更不要说缓存节点故障了。而图中单点部署的缓存节点就成了整体系统中最大的隐患,那我们要如何来解决这个问题,提升缓存的可用性呢?

我们可以通过部署多个节点,同时设计一些方案让这些节点互为备份。这样,当某个节点故障时,它的备份节点可以顶替它继续提供服务。 而这些方案就是我们本节课的重点:分布式缓存的高可用方案。

在我的项目中,我主要选择的方案有 客户端方案、中间代理层方案和服务端方案 三大类:

  • 客户端方案 就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。
  • 中间代理层方案 是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。
  • 服务端方案 就是 Redis 2.4 版本后提出的 Redis Sentinel 方案。

掌握这些方案可以帮助你,抵御部分缓存节点故障导致的,缓存命中率下降的影响,增强你的系统的鲁棒性。

客户端方案

在客户端方案中,你需要关注缓存的写和读两个方面:

  • 写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片;
  • 读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。

下面我就带你一起详细地看一下到底要怎么做。

1. 缓存数据如何分片

单一的缓存节点受到机器内存、网卡带宽和单节点请求量的限制,不能承担比较高的并发,因此我们考虑将数据分片,依照分片算法将数据打散到多个不同的节点上,每个节点上存储部分数据。

这样在某个节点故障的情况下,其他节点也可以提供服务,保证了一定的可用性。这就好比不要把鸡蛋放在同一个篮子里,这样一旦一个篮子掉在地上,摔碎了,别的篮子里还有没摔碎的鸡蛋,不至于一个不剩。

NAME

这个算法最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。 所以我建议你, 如果采用这种方法,最好建立在你对于这组缓存命中率下降不敏感,比如下面还有另外一层缓存来兜底的情况下。

当然了,用一致性 Hash 算法可以很好地解决增加和删减节点时,命中率下降的问题。 在这个算法中,我们将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上 行走,遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面,Key 1 和 Key 2 会落入到 Node 1 中,Key 3、Key 4 会落入到 Node 2 中,Key 5 落入到 Node 3 中,Key 6 落入到 Node 4 中。

NAME

这时如果在 Node 1 和 Node 2 之间增加一个 Node 5,你可以看到原本命中 Node 2 的 Key 3 现在命中到 Node 5,而其它的 Key 都没有变化;同样的道理,如果我们把 Node 3 从集群中移除,那么只会影响到 Key 5 。所以你看, 在增加和删除节点时,只有少量的 Key 会「漂移」到其它节点上, 而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。

NAME

不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:

  • 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
  • 一致性 Hash 算法的脏数据问题。

极端情况下,比如一个有三个节点 A、B、C 承担整体的访问,每个节点的访问量平均,A 故障后,B 将承担双倍的压力(A 和 B 的全部请求),当 B 承担不了流量 Crash 后,C 也将因为要承担原先三倍的流量而 Crash,这就造成了整体缓存系统的雪崩。

说到这儿,你可能觉得很可怕,但也不要太担心, 我们程序员就是要能够创造性地解决各种问题,所以你可以在一致性 Hash 算法中引入虚拟节点的概念。

它将一个缓存节点计算多个 Hash 值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key 将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。

其次,就是一致性 Hash 算法的脏数据问题。为什么会产生脏数据呢? 比方说,在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。

所以,在使用一致性 Hash 算法时一定要设置缓存的过期时间, 这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。

NAME

很显然,数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它也让缓存的使用更加复杂。在 MultiGet(批量获取)场景下,单个节点的访问量并没有减少,同时节点数太多会造成缓存访问的 SLA(即「服务等级协议」,SLA 代表了网站服务可用性)得不到很好的保证,因为根据木桶原则,SLA 取决于最慢、最坏的节点的情况,节点数过多也会增加出问题的概率, 因此我推荐 4 到 6 个节点为佳。

2. Memcached 的主从机制

Redis 本身支持主从的部署方式,但是 Memcached 并不支持,所以我们今天主要来了解一下 Memcached 的主从机制是如何在客户端实现的。

在之前的项目中,我就遇到了单个主节点故障导致数据穿透的问题,这时我为每一组 Master 配置一组 Slave,更新数据时主从同步更新。读取时,优先从 Slave 中读数据,如果读取不到数据就穿透到 Master 读取,并且将数据回种到 Slave 中以保持 Slave 数据的热度。

主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性。

NAME

3. 多副本

其实,主从方式已经能够解决大部分场景的问题,但是对于极端流量的场景下,一组 Slave 通常来说并不能完全承担所有流量,Slave 网卡带宽可能成为瓶颈。

为了解决这个问题,我们考虑在 Master/Slave 之前增加一层副本层,整体架构是这样的:

NAME

在这个方案中,当客户端发起查询请求时,请求首先会先从多个副本组中选取一个副本组发起查询,如果查询失败,就继续查询 Master/Slave,并且将查询的结果回种到所有副本组中,避免副本组中脏数据的存在。

基于成本的考虑,每一个副本组容量比 Master 和 Slave 要小,因此它只存储了更加热的数据。在这套架构中,Master 和 Slave 的请求量会大大减少,为了保证它们存储数据的热度,在实践中我们会把 Master 和 Slave 作为一组副本组使用。

中间代理层方案

虽然客户端方案已经能解决大部分的问题,但是只能在单一语言系统之间复用。例如微博使用 Java 语言实现了这么一套逻辑,我使用 PHP 就难以复用,需要重新写一套,很麻烦。 而中间代理层的方案就可以解决这个问题。 你可以将客户端解决方案的经验移植到代理层中,通过通用的协议(如 Redis 协议)来实现在其他语言中的复用。

如果你来自研缓存代理层,你就可以将客户端方案中的高可用逻辑封装在代理层代码里面,这样用户在使用你的代理层的时候就不需要关心缓存的高可用是如何做的,只需要依赖你的代理层就好了。

除此以外,业界也有很多中间代理层方案,比如 Facebook 的 Mcrouter ,Twitter 的 Twemproxy,豌豆荚的 Codis 。它们的原理基本上可以由一张图来概括:

NAME

看这张图你有什么发现吗? 所有缓存的 读写请求 都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑,不同的开源中间代理层方案中使用的高可用策略各有不同。比如在 Twemproxy 中,Proxy 保证在某一个 Redis 节点挂掉之后会把它从集群中移除,后续的请求将由其他节点来完成;而 Codis 的实现略复杂,它提供了一个叫 Codis Ha 的工具来实现自动从节点提主节点,在 3.2 版本之后换做了 Redis Sentinel 方式,从而实现 Redis 节点的高可用。

服务端方案

Redis 在 2.4 版本中提出了 Redis Sentinel 模式来解决主从 Redis 部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性,整体的架构如下图所示:

NAME

Redis Sentinel 也是集群部署的,这样可以避免 Sentinel 节点挂掉造成无法自动故障恢复的问题,每一个 Sentinel 节点都是无状态的。在 Sentinel 中会配置 Master 的地址,Sentinel 会时刻监控 Master 的状态,当发现 Master 在配置的时间间隔内无响应,就认为 Master 已经挂了,Sentinel 会从从节点中选取一个提升为主节点,并且把所有其他的从节点作为新主的从节点。Sentinel 集群内部在仲裁的时候,会根据配置的值来决定当有几个 Sentinel 节点认为主挂掉可以做主从切换的操作,也就是集群内部需要对缓存节点的状态达成一致才行。

Redis Sentinel 不属于代理层模式,因为对于缓存的写入和读取请求不会经过 Sentinel 节点。Sentinel 节点在架构上和主从是平级的,是作为管理者存在的, 所以可以认为是在服务端提供的一种高可用方案。

课程小结

这就是今天分享的全部内容了,我们一起来回顾一下重点:

分布式缓存的高可用方案主要有三种,首先是客户端方案,一般也称为 Smart Client。我们通过制定一些数据分片和数据读写的策略,可以实现缓存高可用。这种方案的好处是性能没有损耗,缺点是客户端逻辑复杂且在多语言环境下不能复用。

其次,中间代理方案在客户端和缓存节点之间增加了中间层,在性能上会有一些损耗,在代理层会有一些内置的高可用方案,比如 Codis 会使用 Codis Ha 或者 Sentinel。

最后,服务端方案依赖于组件的实现,Memcached 就只支持单机版没有分布式和 HA 的方案,而 Redis 在 2.4 版本提供了 Sentinel 方案可以自动进行主从切换。服务端方案会在运维上增加一些复杂度。

总体而言,分布式缓存的三种方案各有所长,有些团队可能在开发过程中已经积累了 Smart Client 上的一些经验;而有些团队在 Redis 运维上经验丰富,就可以推进 Sentinel 方案;有些团队在存储研发方面有些积累,就可以推进中间代理层方案,甚至可以自研适合自己业务场景的代理层组件,具体的选择还是要看团队的实际情况而定。

4.14 - CH15-缓存-穿透

我用三节课的时间,带你深入了解了缓存,你应该知道,对于缓存来说,命中率是它的生命线。

在低缓存命中率的系统中,大量查询商品信息的请求会穿透缓存到数据库,因为数据库对于并发的承受能力是比较脆弱的。一旦数据库承受不了用户大量刷新商品页面、定向搜索衣服信息,就会导致查询变慢,导致大量的请求阻塞在数据库查询上,造成应用服务器的连接和线程资源被占满,最终导致你的电商系统崩溃。

一般来说,我们的核心缓存的命中率要保持在 99% 以上,非核心缓存的命中率也要尽量保证在 90%,如果低于这个标准,那么你可能就需要优化缓存的使用方式了。

既然缓存的穿透会带来如此大的影响,那么我们该如何减少它的发生呢?本节课,我就带你全面探知,面对缓存穿透时,我们到底有哪些应对措施。不过在此之前,你需要了解「到底什么是缓存穿透」,只有这样,才能更好地考虑如何设计方案解决它。

什么是缓存穿透

缓存穿透其实是指从缓存中没有查到数据,而不得不从后端系统(比如数据库)中查询的情况。你可以把数据库比喻为手机,它是经受不了太多的划痕和磕碰的,所以你需要给它贴个膜再套个保护壳,就能对手机起到一定的保护作用了。

不过,少量的缓存穿透不可避免,对系统也是没有损害的,主要有几点原因:

  • 一方面,互联网系统通常会面临极大数据量的考验,而缓存系统在容量上是有限的,不可能存储系统所有的数据,那么在查询未缓存数据的时候就会发生缓存穿透。
  • 另一方面,互联网系统的数据访问模型一般会遵从 80/20 原则80/20 原则 又称为帕累托法则,是意大利经济学家帕累托提出的一个经济学的理论。它是指在一组事物中,最重要的事物通常只占 20%,而剩余的 80% 的事物确实不重要的。把它应用到数据访问的领域,就是我们会经常访问 20% 的热点数据,而另外的 80% 的数据则不会被经常访问。比如你买了很多衣服,很多书,但是其实经常穿的,经常看的,可能也就是其中很小的一部分。

既然缓存的容量有限,并且大部分的访问只会请求 20% 的热点数据,那么理论上说,我们只需要在有限的缓存空间里存储 20% 的热点数据就可以有效地保护脆弱的后端系统了,也就可以放弃缓存另外 80% 的非热点数据了。所以,这种少量的缓存穿透是不可避免的,但是对系统是没有损害的。

那么什么样的缓存穿透对系统有害呢?答案是大量的穿透请求超过了后端系统的承受范围,造成了后端系统的崩溃。如果把少量的请求比作毛毛细雨,那么一旦变成倾盆大雨,引发洪水,冲倒房屋,肯定就不行了。

产生这种大量穿透请求的场景有很多,接下来,我就带你解析这几种场景以及相应的解决方案。

缓存穿透的解决方案

先来考虑这样一种场景:在你的电商系统的用户表中,我们需要通过用户 ID 查询用户的信息,缓存的读写策略采用 Cache Aside 策略。

那么,如果要读取一个用户表中未注册的用户,会发生什么情况呢?按照这个策略,我们会先读缓存,再穿透读数据库。由于用户并不存在,所以缓存和数据库中都没有查询到数据,因此也就不会向缓存中回种数据(也就是向缓存中设置值的意思),这样当再次请求这个用户数据的时候还是会再次穿透到数据库。在这种场景下,缓存并不能有效地阻挡请求穿透到数据库上,它的作用就微乎其微了。

那如何解决缓存穿透呢?一般来说我们会有两种解决方案: 回种空值以及使用布隆过滤器。

我们先来看看第一种方案。

我们先来看看第一种方案。

回种空值

回顾上面提到的场景,你会发现最大的问题在于数据库中并不存在用户的数据,这就造成无论查询多少次,数据库中永远都不会存在这个用户的数据,穿透永远都会发生。

类似的场景还有一些: 比如由于代码的 bug 导致查询数据库的时候抛出了异常,这样可以认为从数据库查询出来的数据为空,同样不会回种缓存。

那么,当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。 下面是这个流程的伪代码:

Object nullValue = new Object();
try {
  Object valueFromDB = getFromDB(uid); // 从数据库中查询数据
  if (valueFromDB == null) {
    cache.set(uid, nullValue, 10);   // 如果从数据库中查询到空值,就把空值写入缓存,设置较短的超时时间
  } else {
    cache.set(uid, valueFromDB, 1000);
  }
} catch(Exception e) {
  cache.set(uid, nullValue, 10);
}

回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。

所以这个方案,我建议你在使用的时候应该评估一下缓存容量是否能够支撑。 如果需要大量的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时你可以考虑使用布隆过滤器。

使用布隆过滤器

1970 年布隆提出了一种布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。 它的基本思路如下:

我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。

下图是布隆过滤器示意图,我来带你分析一下图内的信息。

NAME

A、B、C 等元素组成了一个集合,元素 D 计算出的 Hash 值所对应的的数组中值是 1,所以可以认为 D 也在集合中。而 F 在数组中的值是 0,所以 F 不在数组中。

那么我们如何使用布隆过滤器来解决缓存穿透的问题呢?

还是以存储用户信息的表为例进行讲解。首先,我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。

新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数组中,相应位置的值。那么当我们需要查询某一个用户的信息时,我们首先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。

NAME

布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1),是常量值。在空间上,相对于其他数据结构它也有很大的优势,比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。

不过,任何事物都有两面性,布隆过滤器也不例外, 它主要有两个缺陷:

  1. 它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中;
  2. 不支持删除元素。

关于第一个缺陷,主要是 Hash 算法的问题。 因为布隆过滤器是由一个二进制数组和一个 Hash 算法组成的,Hash 算法存在着一定的碰撞几率。Hash 碰撞的含义是不同的输入值经过 Hash 运算后得到了相同的 Hash 结果。

本来,Hash 的含义是不同的输入,依据不同的算法映射成独一无二的固定长度的值,也就是我输入字符串 1,根据 CRC32 算法,值是 2212294583。但是现实中 Hash 算法的输入值是无限的,输出值的值空间却是固定的,比如 16 位的 Hash 值的值空间是 65535,那么它的碰撞几率就是 1/65535,即如果输入值的个数超过 65535 就一定会发生碰撞。

那么你可能会问为什么不映射成更长的 Hash 值呢?

因为更长的 Hash 值会带来更高的存储成本和计算成本。即使使用 32 位的 Hash 算法,它的值空间长度是 2 的 32 次幂减一,约等于 42 亿,用来映射 20 亿的用户数据,碰撞几率依然有接近 50%。(笔者疑问:42 亿不能装下 20 亿?)

Hash 的碰撞就造成了两个用户 ID ,A 和 B 会计算出相同的 Hash 值,那么如果 A 是注册的用户,它的 Hash 值对应的数组中的值是 1,那么 B 即使不是注册用户,它在数组中的位置和 A 是相同的,对应的值也是 1, 这就产生了误判。

布隆过滤器的误判有一个特点,就是它只会出现 false positive 的情况。这是什么意思呢?当布隆过滤器判断元素在集合中时,这个元素可能不在集合中。但是一旦布隆过滤器判断这个元素不在集合中时,它一定不在集合中。 这一点非常适合解决缓存穿透的问题。 为什么呢?

你想,如果布隆过滤器会将集合中的元素判定为不在集合中,那么我们就不确定,被布隆过滤器判定为不在集合中的元素,是不是在集合中。假设在刚才的场景中,如果有大量查询未注册的用户信息的请求存在,那么这些请求到达布隆过滤器之后,即使布隆过滤器判断为不是注册用户,那么我们也不确定它是不是真的不是注册用户,那么就还是需要去数据库和缓存中查询,这就使布隆过滤器失去了价值。

所以你看,布隆过滤器虽然存在误判的情况,但是还是会减少缓存穿透的情况发生,只是我们需要尽量减少误判的几率,这样布隆过滤器的判断正确的几率更高,对缓存的穿透也更少。 一个解决方案是:

使用多个 Hash 算法为元素计算出多个 Hash 值,只有所有 Hash 值对应的数组中的值都为 1 时,才会认为这个元素在集合中。

布隆过滤器不支持删除元素的缺陷也和 Hash 碰撞有关。 给你举一个例子,假如两个元素 A 和 B 都是集合中的元素,它们有相同的 Hash 值,它们就会映射到数组的同一个位置。这时我们删除了 A,数组中对应位置的值也从 1 变成 0,那么在判断 B 的时候发现值是 0,也会判断 B 是不在集合中的元素,就会得到错误的结论。

那么我是怎么解决这个问题的呢? 我会让数组中不再只有 0 和 1 两个值,而是存储一个计数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删除了就把这个值从 2 改为 1。这个方案中的数组不再存储 bit 位,而是存储数值,也就会增加空间的消耗。所以,你要依据业务场景来选择是否能够使用布隆过滤器, 比如像是注册用户的场景下,因为用户删除的情况基本不存在,所以还是可以使用布隆过滤器来解决缓存穿透的问题的。

讲了这么多,关于布隆过滤器的使用上,我也给你几个建议:

  1. 选择多个 Hash 函数计算多个 Hash 值,这样可以减少误判的几率;
  2. 布隆过滤器会消耗一定的内存空间,所以在使用时需要评估你的业务场景下需要多大的内存,存储的成本是否可以接受。

总的来说,回种空值和布隆过滤器 是解决缓存穿透问题的两种最主要的解决方案,但是它们也有各自的适用场景,并不能解决所有问题。比方说当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫做 dog-pile effect(狗桩效应),

这是典型的缓存并发穿透的问题, 那么,我们如何来解决这个问题呢? 解决狗桩效应的思路是尽量地减少缓存穿透后的并发,方案也比较简单:

  1. 在代码中,控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。
  2. 通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库。

分布式锁的方式也比较简单,比方说 ID 为 1 的用户是一个热点用户,当他的用户信息缓存失效后,我们需要从数据库中重新加载数据时,先向 Memcached 中写入一个 Key 为 lock.1 的缓存项,然后去数据库里面加载数据,当数据加载完成后再把这个 Key 删掉。这时,如果另外一个线程也要请求这个用户的数据,它发现缓存中有 Key 为“ lock.1 的缓存,就认为目前已经有线程在加载数据库中的值到缓存中了,它就可以重新去缓存中查询数据,不再穿透数据库了。

课程小结

本节课,我带你了解了一些解决缓存穿透的方案,你可以在发现自己的缓存系统命中率下降时,从中得到一些借鉴的思路。我想让你明确的重点是:

  1. 回种空值是一种最常见的解决思路,实现起来也最简单,如果评估空值缓存占据的缓存空间可以接受,那么可以优先使用这种方案;
  2. 布隆过滤器会引入一个新的组件,也会引入一些开发上的复杂度和运维上的成本。所以只有在存在海量查询数据库中,不存在数据的请求时才会使用,在使用时也要关注布隆过滤器对内存空间的消耗;
  3. 对于极热点缓存数据穿透造成的 狗桩效应,可以通过设置分布式锁或者后台线程定时加载的方式来解决。

除此之外,你还需要了解的是,数据库是一个脆弱的资源,它无论是在扩展性、性能还是承担并发的能力上,相比缓存都处于绝对的劣势,所以我们解决缓存穿透问题的 核心目标在于减少对于数据库的并发请求。 了解了这个核心的思想,也许你还会在日常工作中找到其他更好的解决缓存穿透问题的方案。

4.15 - CH16-缓存-CDN

前面几节课,我带你了解了缓存的定义以及常用缓存的使用姿势,现在,你应该对包括本地缓存、分布式缓存等缓存组件的适用场景和使用技巧有了一定了解了。结合在14 讲中我提到的客户端高可用方案,你会将单个缓存节点扩展为高可用的缓存集群,现在,你的电商系统架构演变成了下面这样:

NAME

在这个架构中我们使用分布式缓存对动态请求数据的读取做了加速,但是在我们的系统中 存在着大量的静态资源请求:

  1. 对于移动 APP 来说,这些静态资源主要是图片、视频和流媒体信息。
  2. 对于 Web 网站来说,则包括了 JavaScript 文件,CSS 文件,静态 HTML 文件等等。

具体到你的电商系统来说,商品的图片,介绍商品使用方法的视频等等静态资源,现在都放在了 Nginx 等 Web 服务器上,它们的读请求量极大,并且对访问速度的要求很高,并且占据了很高的带宽,这时会出现访问速度慢,带宽被占满影响动态请求的问题, 那么你就需要考虑如何针对这些静态资源进行读加速了。

静态资源加速的考虑点

你可能会问:「我们是否也可以使用分布式缓存来解决这个问题呢?」答案是否定的。一般来说,图片和视频的大小会在几兆到几百兆之间不等,如果我们的应用服务器和分布式缓存都部署在北京的机房里,这时一个杭州的用户要访问缓存中的一个视频,那这个视频文件就需要从北京传输到杭州,期间会经过多个公网骨干网络,延迟很高,会让用户感觉视频打开很慢,严重影响到用户的使用体验。

所以,静态资源访问的关键点是 就近访问, 即北京用户访问北京的数据,杭州用户访问杭州的数据,这样才可以达到性能的最优。

你可能会说:「那我们在杭州也自建一个机房,让用户访问杭州机房的数据就好了呀。」可用户遍布在全国各地,有些应用可能还有国外的用户,我们不可能在每个地域都自建机房,这样成本太高了。

另外,单个视频和图片等静态资源很大,并且访问量又极高,如果使用业务服务器和分布式缓存来承担这些流量,无论是对于内网还是外网的带宽都会是很大的考验。

所以我们考虑在业务服务器的上层,增加一层特殊的缓存,用来承担绝大部分对于静态资源的访问,这一层特殊缓存的节点需要遍布在全国各地,这样可以让用户选择最近的节点访问。缓存的命中率也需要一定的保证,尽量减少访问资源存储源站的请求数量(回源请求)。 这一层缓存就是我们这节课的重点:CDN。

CDN 的关键技术

CDN(Content Delivery Network/Content Distribution Network,内容分发网络)。简单来说,CDN 就是将静态的资源分发到,位于多个地理位置机房中的服务器上,因此它能很好地解决数据就近访问的问题,也就加快了静态资源的访问速度。

在大中型公司里面,CDN 的应用非常的普遍,大公司为了提供更稳定的 CDN 服务会选择自建 CDN,而大部分公司基于成本的考虑还是会选择专业的 CDN 厂商,网宿、阿里云、腾讯云、蓝汛等等,其中网宿和蓝汛是老牌的 CDN 厂商,阿里云和腾讯云是云厂商提供的服务,如果你的服务部署在云上可以选择相应云厂商的 CDN 服务,这些 CDN 厂商都是现今行业内比较主流的。

对于 CDN 来说,你可能已经从运维的口中听说过,并且也了解了它的作用。但是当让你来配置 CDN 或者是排查 CDN 方面的问题时,你就有可能因为不了解它的原理而束手无策了。

所以,我先来带你了解一下,要搭建一个 CDN 系统需要考虑哪两点:

  1. 如何将用户的请求映射到 CDN 节点上;
  2. 如何根据用户的地理位置信息选择到比较近的节点。

下面我就带你具体了解一下 CDN 系统是如何实现加速用户对于静态资源的请求的。

1. 如何让用户的请求到达 CDN 节点

首先,我们考虑一下如何让用户的请求到达 CDN 节点,你可能会觉得,这很简单啊,只需要告诉用户 CDN 节点的 IP 地址,然后请求这个 IP 地址上面部署的 CDN 服务就可以了啊。 但是这样会有一个问题: 就是我们使用的是第三方厂商的 CDN 服务,CDN 厂商会给我们一个 CDN 的节点 IP,比如说这个 IP 地址是「111.202.34.130」,那么我们的电商系统中的图片的地址很可能是这样的:「http://111.202.34.130/1.jpg」, 这个地址是要存储在数据库中的。

那么如果这个节点 IP 发生了变更怎么办?或者我们如果更改了 CDN 厂商怎么办?是不是要修改所有的商品的 url 域名呢?这就是一个比较大的工作量了。所以,我们要做的事情是 将第三方厂商提供的 IP 隐藏起来,给到用户的最好是一个本公司域名的子域名。

那么如何做到这一点呢? 这就需要依靠 DNS 来帮我们解决域名映射的问题了。

DNS(Domain Name System,域名系统)实际上就是一个存储域名和 IP 地址对应关系的分布式数据库。而域名解析的结果一般有两种:

  • 一种叫做 A 记录,返回的是域名对应的 IP 地址;

  • 另一种是 CNAME 记录,返回的是另一个域名

    也就是说当前域名的解析要跳转到另一个域名的解析上,实际上 www.baidu.com 域名的解析结果就是一个 CNAME 记录,域名的解析被跳转到 www.a.shifen.com 上了,我们正是利用 CNAME 记录来解决域名映射问题的,具体是怎么解决的呢?我给你举个例子。

比如你的公司的一级域名叫做 example.com,那么你可以给你的图片服务的域名定义为 img.example.com,然后将这个域名的解析结果的 CNAME 配置到 CDN 提供的域名上。比如 uclound 可能会提供一个域名是 80f21f91.cdn.ucloud.com.cn 这个域名。这样你的电商系统使用的图片地址可以是 http://img.example.com/1.jpg

用户在请求这个地址时,DNS 服务器会将域名解析到 80f21f91.cdn.ucloud.com.cn 域名上,然后再将这个域名解析为 CDN 的节点 IP,这样就可以得到 CDN 上面的资源数据了。

不过,这里面有一个问题: 因为域名解析过程是分级的,每一级有专门的域名服务器承担解析的职责,所以,域名的解析过程有可能需要跨越公网做多次 DNS 查询,在性能上是比较差的。

NAME

从 「域名分级解析示意图」中你可以看出 DNS 分为很多种,有根 DNS,顶级 DNS 等等。除此之外还有两种 DNS 需要特别留意:

  • 一种是 Local DNS,它是由你的运营商提供的 DNS,一般域名解析的第一站会到这里;
  • 另一种是权威 DNS,它的含义是自身数据库中存储了这个域名对应关系的 DNS。

下面我以 www.baidu.com 这个域名为例给你简单介绍一下域名解析的过程:

  • 一开始,域名解析请求先会检查本机的 hosts 文件,查看是否有 www.baidu.com 对应的 IP;
  • 如果没有的话,就请求 Local DNS 是否有域名解析结果的缓存,如果有就返回,标识是从非权威 DNS 返回的结果;
  • 如果没有,就开始 DNS 的迭代查询:
    1. 先请求根 DNS,根 DNS 返回顶级 DNS(.com)的地址;
    2. 再请求 .com 顶级 DNS,得到 baidu.com 的域名服务器地址;
    3. 再从 baidu.com 的域名服务器中查询到 www.baidu.com 对应的 IP 地址,返回这个 IP 地址的同时,标记这个结果是来自于权威 DNS 的结果,同时写入 Local DNS 的解析结果缓存,这样下一次的解析同一个域名就不需要做 DNS 的迭代查询了。

经过了向多个 DNS 服务器做查询之后,整个 DNS 的解析的时间有可能会到秒级别, 那么我们如何来解决这个性能问题呢?

一个解决的思路是: 在 APP 启动时,对需要解析的域名做预先解析,然后把解析的结果缓存到本地的一个 LRU 缓存里面。这样当我们要使用这个域名的时候,只需要从缓存中直接拿到所需要的 IP 地址就好了,如果缓存中不存在才会走整个 DNS 查询的过程。 同时, 为了避免 DNS 解析结果的变更造成缓存内数据失效,我们可以启动一个定时器,定期地更新缓存中的数据。

我曾经测试过这种方式, 对于 HTTP 请求的响应时间的提升是很明显的,原先 DNS 解析时间经常会超过 1s,使用这种方式后,DNS 解析时间可以控制在 200ms 之内,整个 HTTP 请求的过程也可以减少大概 80ms~100ms。

NAME

这里总结一下, 将用户的请求映射到 CDN 服务器上,是使用 CDN 时需要解决的一个核心的问题,而 CNAME 记录在 DNS 解析过程中可以充当一个中间代理层的角色,可以把将用户最初使用的域名代理到正确的 IP 地址上。图片:

NAME

现在,剩下的一个问题就是如何找到更近的 CDN 节点了,而 GSLB 承担了这个职责。

2. 如何找到离用户最近的 CDN 节点

GSLB(Global Server Load Balance,全局负载均衡),它的含义是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。 它有两方面的作用:

  • 一方面,它是一种负载均衡服务器,负载均衡,顾名思义嘛,指的是让流量平均分配使得下面管理的服务器的负载更平均;
  • 另一方面,它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。

GSLB 可以 通过多种策略,来保证返回的 CDN 节点和用户尽量保证在同一地缘区域,比如说可以将用户的 IP 地址按照地理位置划分为若干的区域,然后将 CDN 节点对应到一个区域上,然后根据用户所在区域来返回合适的节点;也可以通过发送数据包测量 RTT 的方式来决定返回哪一个节点。 不过,这些原理不是本节课重点内容, 你了解一下就可以了,我不做详细的介绍。

有了 GSLB 之后,节点的解析过程变成了下图中的样子:

NAME

当然,是否能够从 CDN 节点上获取到资源还取决于 CDN 的同步延时。 一般,我们会通过 CDN 厂商的接口将静态的资源写入到某一个 CDN 节点上 ,再由 CDN 内部的同步机制将资源分散同步到每个 CDN 节点,即使 CDN 内部网络经过了优化,这个同步的过程是有延时的,一旦我们无法从选定的 CDN 节点上获取到数据,我们就不得不从源站获取数据,而用户网络到源站的网络可能会跨越多个主干网,这样不仅性能上有损耗,也会消耗源站的带宽,带来更高的研发成本。所以,我们在使用 CDN 的时候需要关注 CDN 的命中率和源站的带宽情况。

课程小结

本节课,我主要带你了解了 CDN 对静态资源进行加速的原理和使用的核心技术,这里你需要了解的重点有以下几点:

  1. DNS 技术是 CDN 实现中使用的核心技术,可以将用户的请求映射到 CDN 节点上;
  2. DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间;
  3. GSLB 可以给用户返回一个离着他更近的节点,加快静态资源的访问速度。

作为一个服务端开发人员,你可能会忽略 CDN 的重要性,对于偶尔出现的 CDN 问题嗤之以鼻,觉得这个不是我们应该关心的内容, 这种想法是错的。

CDN 是我们系统的门面,其缓存的静态数据,如图片和视频数据的请求量很可能是接口请求数据的几倍甚至更高,一旦发生故障,对于整体系统的影响是巨大的。另外 CDN 的带宽历来是我们研发成本的大头, 尤其是目前处于小视频和直播风口上, 大量的小视频和直播研发团队都在绞尽脑汁地减少 CDN 的成本。由此看出,CDN 是我们整体系统至关重要的组成部分,而它作为一种特殊的缓存,其命中率和可用性也是我们服务端开发人员需要重点关注的指标。

4.16 - CH17-缓存-数据迁移

在 「数据库优化方案(二):写入数据量增加时,如何实现分库分表?」中我曾经提到,由于 MySQL 不像 MongoDB 那样支持数据的 Auto Sharding(自动分片),所以无论是将 MySQL 单库拆分成多个数据库,还是由于数据存储的瓶颈,不得不将多个数据库拆分成更多的数据库时,你都要考虑如何做数据的迁移。

其实,在实际工作中,不只是对数据库拆分时会做数据迁移, 很多场景都需要你给出数据迁移的方案, 比如说某一天,你的老板想要将应用从自建机房迁移到云上,那么你就要考虑将所有自建机房中的数据,包括 MySQL,Redis,消息队列等组件中的数据,全部迁移到云上,这无论对哪种规模的公司来说都是一项浩瀚的工程,所以你需要在迁移之前,准备完善的迁移方案。

数据的迁移的问题比较重要,也比较繁琐,也是开发和运维同学关注的重点。

如何平滑地迁移数据库中的数据

你可能会认为:数据迁移无非是将数据从一个数据库拷贝到另一个数据库,可以通过 MySQL 主从同步的方式做到准实时的数据拷贝;也可以通过 mysqldump 工具将源库的数据导出,再导入到新库,这有什么复杂的呢?

其实,这两种方式只能支持单库到单库的迁移,无法支持单库到多库多表的场景。而且即便是单库到单库的迁移,迁移过程也需要满足以下几个目标:

  • 迁移应该是在线的迁移,也就是在迁移的同时还会有数据的写入;
  • 数据应该保证完整性,也就是说在迁移之后需要保证新的库和旧的库的数据是一致的;
  • 迁移的过程需要做到可以回滚,这样一旦迁移的过程中出现问题,可以立刻回滚到源库,不会对系统的可用性造成影响。

如果你使用 Binlog 同步的方式,在同步完成后再修改代码,将主库修改为新的数据库,这样就不满足可回滚的要求,一旦迁移后发现问题,由于已经有增量的数据写入了新库而没有写入旧库,不可能再将数据库改成旧库。

一般来说,我们有两种方案可以做数据库的迁移。

双写方案

第一种方案我称之为双写,其实说起来也很简单,它可以分为以下几个步骤:

  1. 将新的库配置为源库的从库,用来同步数据;

    如果需要将数据同步到多库多表,那么可以使用一些第三方工具获取 Binlog 的增量日志(比如开源工具 Canal),在获取增量日志之后就可以按照分库分表的逻辑写入到新的库表中了。

  2. 同时,我们需要改造业务代码,在数据写入的时候,不仅要写入旧库,也要写入新库。

    当然,基于性能的考虑,我们可以异步地写入新库,只要保证旧库写入成功即可。 但是,我们需要注意的是, 需要将写入新库失败的数据记录在单独的日志中,这样方便后续对这些数据补写,保证新库和旧库的数据一致性。

  3. 然后,我们就可以开始校验数据了。由于数据库中数据量很大,做全量的数据校验不太现实。你可以抽取部分数据,具体数据量依据总体数据量而定,只要保证这些数据是一致的就可以。

  4. 如果一切顺利,我们就可以将读流量切换到新库了。

    由于担心一次切换全量读流量可能会对系统产生未知的影响,所以这里 最好采用灰度的方式来切换, 比如开始切换 10% 的流量,如果没有问题再切换到 50% 的流量,最后再切换到 100%。

  5. 由于有双写的存在,所以在切换的过程中出现任何的问题,都可以将读写流量随时切换到旧库去,保障系统的性能。

  6. 在观察了几天发现数据的迁移没有问题之后,就可以将数据库的双写改造成只写新库,数据的迁移也就完成了。

NAME

其中,最容易出问题的步骤就是数据校验的工作, 所以,我建议你在未开始迁移数据之前先写好数据校验的工具或者脚本,在测试环境上测试充分之后,再开始正式的数据迁移。

如果是将数据从自建机房迁移到云上,你也可以使用这个方案, 只是你需要考虑的一个重要的因素是: 自建机房到云上的专线的带宽和延迟,你需要尽量减少跨专线的读操作,所以在切换读流量的时候,你需要保证自建机房的应用服务器读取本机房的数据库,云上的应用服务器读取云上的数据库。这样在完成迁移之前,只要将自建机房的应用服务器停掉,并且将写入流量都切到新库就可以了。

NAME

这种方案是一种比较通用的方案,无论是迁移 MySQL 中的数据,还是迁移 Redis 中的数据,甚至迁移消息队列都可以使用这种方式, 你在实际的工作中可以直接拿来使用。

这种方式的 好处是: 迁移的过程可以随时回滚,将迁移的风险降到了最低。 劣势是: 时间周期比较长,应用有改造的成本。

级联同步方案

这种方案也比较简单,比较适合数据从自建机房向云上迁移的场景。因为迁移上云,最担心云上的环境和自建机房的环境不一致,会导致数据库在云上运行时,因为参数配置或者硬件环境不同出现问题。

所以,我们会在自建机房准备一个备库,在云上环境上准备一个新库,通过级联同步的方式在自建机房留下一个可回滚的数据库,具体的步骤如下:

  1. 先将新库配置为旧库的从库,用作数据同步;
  2. 再将一个备库配置为新库的从库,用作数据的备份;
  3. 等到三个库的写入一致后,将数据库的读流量切换到新库;
  4. 然后暂停应用的写入,将业务的写入流量切换到新库(由于这里需要暂停应用的写入,所以需要安排在业务的低峰期)。
NAME

这种方案的回滚方案也比较简单, 可以先将读流量切换到备库,再暂停应用的写入,将写流量切换到备库,这样所有的流量都切换到了备库,也就是又回到了自建机房的环境,就可以认为已经回滚了。

NAME

上面的级联迁移方案可以应用在,将 MySQL 从自建机房迁移到云上的场景,也可以应用在将 Redis 从自建机房迁移到云上的场景, 如果你有类似的需求可以直接拿来应用。

这种方案 优势是 简单易实施,在业务上基本没有改造的成本;

缺点是 在切写的时候需要短暂的停止写入,对于业务来说是有损的,不过如果在业务低峰期来执行切写,可以将对业务的影响降至最低。

数据迁移时如何预热缓存

另外,在从自建机房向云上迁移数据时,我们也需要考虑缓存的迁移方案是怎样的。那么你可能会说:缓存本来就是作为一个中间的存储而存在的,我只需要在云上部署一个空的缓存节点,云上的请求也会穿透到云上的数据库,然后回种缓存,对于业务是没有影响的。

你说的没错,但是你还需要考虑的是缓存的命中率。

如果你部署一个空的缓存,那么所有的请求就都穿透到数据库,数据库可能因为承受不了这么大的压力而宕机,这样你的服务就会不可用了。 所以,缓存迁移的重点是保持缓存的热度。

刚刚我提到,Redis 的数据迁移可以使用双写的方案或者级联同步的方案,所以在这里我就不考虑 Redis 缓存的同步了,而是以 Memcached 为例来说明。

使用副本组预热缓存

在「缓存的使用姿势(二):缓存如何做到高可用?」中,我曾经提到,为了保证缓存的可用性,我们可以部署多个副本组来尽量将请求阻挡在数据库层之上。

数据的写入流程是写入 Master、Slave 和所有的副本组,而在读取数据的时候,会先读副本组的数据,如果读取不到再到 Master 和 Slave 里面加载数据,再写入到副本组中。 那么,我们就可以在云上部署一个副本组, 这样,云上的应用服务器读取云上的副本组,如果副本组没有查询到数据,就可以从自建机房部署的主从缓存上加载数据,回种到云上的副本组上。

NAME

当云上部署的副本组足够热之后,也就是缓存的命中率达到至少 90%,就可以将云机房上的缓存服务器的主从都指向这个副本组,这时迁移也就完成了。

这种方式足够简单,不过有一个致命的问题是: 如果云上的请求穿透云上的副本组,到达自建机房的主从缓存时,这个过程是需要跨越专线的。

这不仅会占用较多专线的带宽,同时专线的延迟相比于缓存的读取时间是比较大的,一般,即使是本地的不同机房之间的延迟也会达到 2ms~3ms,那么,一次前端请求可能会访问十几次甚至几十次的缓存,一次请求就会平白增加几十毫秒甚至过百毫秒的延迟,会极大地影响接口的响应时间,因此在实际项目中我们很少使用这种方案。

但是,这种方案给了我们思路, 让我们可以通过方案的设计在系统运行中自动完成缓存的预热,所以,我们对副本组的方案做了一些改造,以尽量减少对专线带宽的占用。

改造副本组方案预热缓存

改造后的方案对读写缓存的方式进行改造,步骤是这样的:

  1. 在云上部署多组 mc 的副本组,自建机房在接收到写入请求时,会优先写入自建机房的缓存节点,异步写入云上部署的 mc 节点;
  2. 在处理自建机房的读请求时,会指定一定的流量,比如 10%,优先走云上的缓存节点,这样虽然也会走专线穿透回自建机房的缓存节点,但是流量是可控的;
  3. 当云上缓存节点的命中率达到 90% 以上时,就可以在云上部署应用服务器,让云上的应用服务器完全走云上的缓存节点就可以了。
NAME

使用了这种方式,我们可以实现缓存数据的迁移,又可以尽量控制专线的带宽和请求的延迟情况, 你也可以直接在项目中使用。

课程小结

以上我提到的数据迁移的方案,都是我在实际项目中,经常用到的、经受过实战考验的方案,希望你能通过这节课的学习,将这些方案运用到你的项目中,解决实际的问题。与此同时,我想再次跟你强调一下本节课的重点内容:

双写的方案是数据库、Redis 迁移的通用方案, 你可以在实际工作中直接加以使用。 双写方案中最重要的,是通过数据校验来保证数据的一致性,这样就可以在迁移过程中随时回滚;

如果你需要将自建机房的数据迁移到云上,那么也可以考虑 使用级联复制的方案, 这种方案会造成数据的短暂停写,需要在业务低峰期执行;

缓存的迁移重点,是保证云上缓存的命中率,你可以 使用改进版的副本组方式来迁移, 在缓存写入的时候,异步写入云上的副本组,在读取时放少量流量到云上副本组,从而又可以迁移部分数据到云上副本组,又能尽量减少穿透给自建机房造成专线延迟的问题。

如果你作为项目的负责人, 那么在迁移的过程中,你一定要制定周密的计划:如果是数据库的迁移,那么数据的校验应该是你最需要花费时间来解决的问题。

如果是自建机房迁移到云上,那么专线的带宽一定是你迁移过程中的一个瓶颈点,你需要在迁移之前梳理清楚,有哪些调用需要经过专线,占用带宽的情况是怎样的,带宽的延时是否能够满足要求。你的方案中也需要尽量做到在迁移过程中,同机房的服务,调用同机房的缓存和数据库,尽量减少对于专线带宽资源的占用。

4.17 - CH18-消息-基础

在课程一开始,我就带你了解了高并发系统设计的三个目标:性能、可用性和可扩展性,而在提升系统性能方面,我们一直关注的是系统的查询性能。也用了很多的篇幅去讲解数据库的分布式改造,各类缓存的原理和使用技巧。 究其原因在于, 我们遇到的大部分场景都是读多写少, 尤其是在一个系统的初级阶段。

比如说,一个社区的系统初期一定是只有少量的种子用户在生产内容,而大部分的用户都在「围观」别人在说什么。此时,整体的流量比较小,而写流量可能只占整体流量的百分之一,那么即使整体的 QPS 到了 10000 次 / 秒,写请求也只是到了每秒 100 次,如果要对写请求做性能优化,它的性价比确实不太高。

但是,随着业务的发展,你可能会遇到一些存在 高并发写请求的场景,其中秒杀抢购就是最典型的场景。 假设你的商城策划了一期秒杀活动,活动在第五天的 00:00 开始,仅限前 200 名,那么秒杀即将开始时,后台会显示用户正在疯狂地刷新 APP 或者浏览器来保证自己能够尽量早的看到商品。

这时,你面对的依旧是读请求过高, 那么应对的措施有哪些呢?

因为用户查询的是少量的商品数据,属于 查询的热点数据 ,你可以采用缓存策略,将请求尽量挡在上层的缓存中,能被静态化的数据,比如说商城里的图片和视频数据,尽量做到静态化,这样就可以命中 CDN 节点缓存 ,减少 Web 服务器的查询量和带宽负担。Web 服务器比如 Nginx 可以直接访问分布式缓存节点,这样可以避免请求到达 Tomcat 等业务服务器。

当然,你可以加上一些限流的策略,比如,对于短时间之内来自某一个用户、某一个 IP 或者某一台设备的重复请求做丢弃处理。

通过这几种方式,你发现自己可以将请求尽量挡在数据库之外了。

稍微缓解了读请求之后,00:00 分秒杀活动准时开始,用户瞬间向电商系统请求生成订单,扣减库存,用户的这些写操作都是不经过缓存直达数据库的。1 秒钟之内,有 1 万个数据库连接同时达到,系统的数据库濒临崩溃,寻找能够应对如此高并发的写请求方案迫在眉睫。这时你想到了消息队列。

我所理解的消息队列

关于消息队列是什么,你可能有所了解了,所以有关它的概念讲解,就不是本节课的重点,这里只聊聊我自己对消息队列的看法。在我历年的工作经历中,我一直把消息队列看作暂时存储数据的一个容器,认为消息队列是一个平衡低速系统和高速系统处理任务时间差的工具, 我给你举个形象的例子。

比方说,古代的臣子经常去朝见皇上陈述一些国家大事,等着皇上拍板做决策。但是大臣很多,如果同时去找皇上,你说一句我说一句,皇上肯定会崩溃。后来变成臣子到了午门之后要原地等着皇上将他们一个一个地召见进大殿商议国事,这样就可以缓解皇上处理事情的压力了。你可以把午门看作一个暂时容纳臣子的容器,也就是我们所说的消息队列。

其实,你在一些组件中都会看到消息队列的影子:

  • 在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理这些任务;
  • 操作系统中,中断的下半部分也会使用工作队列来实现延后执行;
  • 我们在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个工作线程来处理。
  • ……

总之,队列是在系统设计时一种常见的组件。

那么我们如何用消息队列解决秒杀场景下的问题呢?接下来,我们来结合具体的例子来看看消息队列在秒杀场景下起到的作用。

削去秒杀场景下的峰值写流量

刚才提到,在秒杀场景下,短时间之内数据库的写流量会很高,那么依照我们以前的思路应该对数据做分库分表。如果已经做了分库分表,那么就需要扩展更多的数据库来应对更高的写流量。但是无论是分库分表,还是扩充更多的数据库,都会比较复杂,原因是你需要将数据库中的数据做迁移,这个时间就要按天甚至按周来计算了。

而在秒杀场景下,高并发的写请求并不是持续的,也不是经常发生的,而只有在秒杀活动开始后的几秒或者十几秒时间内才会存在。为了应对这十几秒的瞬间写高峰 ,就要花费几天甚至几周的时间来扩容数据库,再在秒杀之后花费几天的时间来做缩容, 这无疑是得不偿失的。

所以,我们的思路是: 将秒杀请求暂存在消息队列中,然后业务服务器会响应用户 「秒杀结果正在计算中」,释放了系统资源之后再处理其它用户的请求。

我们会在后台启动若干个队列处理程序,消费消息队列中的消息,再执行校验库存、下单等逻辑。因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的 。而请求是可以在消息队列中被短暂地堆积, 当库存被消耗完之后,消息队列中堆积的请求就可以被丢弃了

NAME

这就是消息队列在秒杀系统中最主要的作用: 削峰填谷, 也就是说它可以削平短暂的流量高峰,虽说堆积会造成请求被短暂延迟处理,但是只要我们时刻监控消息队列中的堆积长度,在堆积量超过一定量时,增加队列处理机数量,来提升消息的处理能力就好了,而且秒杀的用户对于短暂延迟知晓秒杀的结果,也是有一定容忍度的。

这里需要注意一下, 我所说的是 「 短暂 」延迟,如果长时间没有给用户公示秒杀结果,那么用户可能就会怀疑你的秒杀活动有猫腻了。所以,在使用消息队列应对流量峰值时,需要对队列处理的时间、前端写入流量的大小,数据库处理能力做好评估,然后根据不同的量级来决定部署多少台队列处理程序。

比如你的秒杀商品有 1000 件,处理一次购买请求的时间是 500ms,那么总共就需要 500s 的时间。这时,你部署 10 个队列处理程序,那么秒杀请求的处理时间就是 50s,也就是说用户需要等待 50s 才可以看到秒杀的结果,这是可以接受的。这时会并发 10 个请求到达数据库,并不会对数据库造成很大的压力。

通过异步处理简化秒杀请求中的业务流程

其实,在大量的写请求 「攻击」你的电商系统的时候,消息队列除了发挥主要的削峰填谷的作用之外,还可以实现 异步处理 来简化秒杀请求中的业务流程,提升系统的性能。

你想,在刚才提到的秒杀场景下,我们在处理购买请求时,需要 500ms。这时,你分析了一下整个的购买流程,发现 这里面会有主要的业务逻辑,也会有次要的业务逻辑: 比如说,主要的流程是生成订单、扣减库存;次要的流程可能是我们在下单购买成功之后会给用户发放优惠券,会增加用户的积分。

假如发放优惠券的耗时是 50ms,增加用户积分的耗时也是 50ms,那么如果我们将发放优惠券、增加积分的操作放在另外一个队列处理机中执行,那么整个流程就缩短到了 400ms,性能提升了 20%,处理这 1000 件商品的时间就变成了 400s。如果我们还是希望能在 50s 之内看到秒杀结果的话,只需要部署 8 个队列程序就好了。

经过将一些业务流程异步处理之后,我们的秒杀系统部署结构也会有所改变:

NAME

解耦实现秒杀系统模块之间松耦合

除了异步处理和削峰填谷以外,消息队列在秒杀系统中起到的另一个作用是 解耦合

比如数据团队对你说,在秒杀活动之后想要统计活动的数据,借此来分析活动商品的受欢迎程度、购买者人群的特点以及用户对于秒杀互动的满意程度等等指标。 而我们需要将大量的数据发送给数据团队 ,那么要怎么做呢?

一个思路是: 可以使用 HTTP 或者 RPC 的方式来同步地调用,也就是数据团队这边提供一个接口,我们实时将秒杀的数据推送给它, 但是这样调用会有两个问题:

  • 整体系统的耦合性比较强,当数据团队的接口发生故障时,会影响到秒杀系统的可用性。
  • 当数据系统需要新的字段,就要变更接口的参数,那么秒杀系统也要随着一起变更。

这时,我们可以考虑使用消息队列降低业务系统和数据系统的直接耦合度。

秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后数据团队再订阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。

秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时,当数据系统需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。

NAME

异步处理、解耦合和削峰填谷 是消息队列在秒杀系统设计中起到的主要作用,其中,

  • 异步处理可以简化业务流程中的步骤,提升系统性能;
  • 削峰填谷可以削去到达秒杀系统的峰值流量,让业务逻辑的处理更加缓和;
  • 解耦合可以将秒杀系统和数据系统解耦开,这样两个系统的任何变更都不会影响到另一个系统,

如果你的系统想要提升写入性能,实现系统的低耦合,想要抵挡高并发的写流量,那么你就可以考虑使用消息队列来完成。

课程小结

本节课,我结合自己的实际经验,主要带你了解了,消息队列在高并发系统设计中起到的作用,以及一些注意事项,你需要了解的重点如下:

  • 削峰填谷是消息队列最主要的作用, 但是会造成请求处理的延迟
  • 异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消息存在着丢失的风险,我们需要考虑如何确保消息一定到达。
  • 解耦合可以提升你的整体系统的鲁棒性。

当然,你要知道,在使用消息队列之后虽然可以解决现有的问题,但是系统的复杂度也会上升。比如上面提到的业务流程中,同步流程和异步流程的边界在哪里?消息是否会丢失,是否会重复?请求的延迟如何能够减少?消息接收的顺序是否会影响到业务流程的正常执行?如果消息处理流程失败了之后是否需要补发? 这些问题都是我们需要考虑的。

我会利用接下来的两节课,针对最主要的两个问题来讲讲解决思路:一个是如何处理消息的丢失和重复,另一个是如何减少消息的延迟。

引入了消息队列的同时也会引入了新的问题,需要新的方案来解决,这就是系统设计的挑战,也是系统设计独有的魅力,而我们也会在这些挑战中不断提升技术能力和系统设计能力。

4.18 - CH19-消息-投递

经过上一节课,我们在电商系统中增加了消息队列,用它来对峰值写流量做削峰填谷,对次要的业务逻辑做异步处理,对不同的系统模块做解耦合。因为业务逻辑从同步代码中移除了,所以,我们也要有相应的队列处理程序来处理消息、执行业务逻辑, 这时,你的系统架构变成了下面的样子:

NAME

这是一个简化版的架构图,实际上,随着业务逻辑越来越复杂,会引入更多的外部系统和服务来解决业务上的问题。比如说,我们会引入 Elasticsearch 来解决商品和店铺搜索的问题,也会引入审核系统,来对售卖的商品、用户的评论做自动的和人工的审核,你会越来越多地使用消息队列与外部系统解耦合,以及提升系统性能。

比如说,你的电商系统需要上一个新的红包功能:用户在购买一定数量的商品之后,由你的系统给用户发一个现金的红包,鼓励用户消费。由于发放红包的过程不应该在购买商品的主流程之内,所以你考虑使用消息队列来异步处理。 这时,你发现了一个问题: 如果消息在投递的过程中发生丢失,那么用户就会因为没有得到红包而投诉。相反,如果消息在投递的过程中出现了重复,那么你的系统就会因为发送两个红包而损失。

那么我们如何保证,产生的消息一定会被消费到,并且只被消费一次呢 ?这个问题虽然听起来很浅显,很好理解,但是实际上却藏着很多玄机,本节课我就带你深入探讨。

消息为什么会丢失

如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列,到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实,主要存在三个场景:

  • 消息从生产者写入到消息队列的过程。
  • 消息在消息队列中的存储场景。
  • 消息被消费者消费的过程。
NAME

接下来,我就针对每一个场景,详细地剖析一下,这样你可以针对不同的场景选择合适的,减少消息丢失的解决方案。

1. 在消息生产的过程中丢失消息

在这个环节中主要有两种情况。

首先,消息的生产者一般是我们的业务服务器,消息队列是独立部署在单独的服务器上的。两者之间的网络虽然是内网,但是也会存在抖动的可能,而一旦发生抖动,消息就有可能因为网络的错误而丢失。

针对这种情况,我建议你采用的方案是消息重传: 也就是当你发现发送超时后你就将消息重新发一次,但是你也不能无限制地重传消息。一般来说,如果不是消息队列发生故障,或者是到消息队列的网络断开了,重试 2~3 次就可以了。

不过,这种方案可能会造成消息的重复,从而导致在消费的时候会重复消费同样的消息。比方说,消息生产时由于消息队列处理慢或者网络的抖动,导致虽然最终写入消息队列成功,但在生产端却超时了,生产者重传这条消息就会形成重复的消息,那么针对上面的例子,直观显示在你面前的就会是你收到了两个现金红包。

那么消息发送到了消息队列之后是否就万无一失了呢?当然不是, 在消息队列中消息仍然有丢失的风险。

2. 在消息队列中丢失消息

拿 Kafka 举例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中,然后再找合适的时机刷新到磁盘上。

比如,Kafka 可以配置当达到某一时间间隔,或者累积一定的消息数量的时候再刷盘, 也就是所说的异步刷盘。

来看一个形象的比喻:假如你经营一个图书馆,读者每还一本书你都要去把图书归位,不仅工作量大而且效率低下,但是如果你可以选择每隔 3 小时,或者图书达到一定数量的时候再把图书归位,这样可以把同一类型的书一起归位,节省了查找图书位置的时间,这样就可以提高效率了。

不过,如果发生机器掉电或者机器异常重启,那么 Page Cache 中还没有来得及刷盘的消息就会丢失了。 那么怎么解决呢?

你可能会把刷盘的间隔设置很短,或者设置累积一条消息就就刷盘,但这样频繁刷盘会对性能有比较大的影响,而且从经验来看,出现机器宕机或者掉电的几率也不高, 所以我不建议你这样做。

NAME

如果你的电商系统对消息丢失的容忍度很低, 那么你可以考虑以集群方式部署 Kafka 服务,通过部署多个副本备份数据,保证消息尽量不丢失。

那么它是怎么实现的呢?

Kafka 集群中有一个 Leader 负责消息的写入和消费,可以有多个 Follower 负责数据的备份。Follower 中有一个特殊的集合叫做 ISR(in-sync replicas),当 Leader 故障时,新选举出来的 Leader 会从 ISR 中选择,默认 Leader 的数据会异步地复制给 Follower,这样在 Leader 发生掉电或者宕机时,Kafka 会从 Follower 中消费消息,减少消息丢失的可能。

由于默认消息是异步地从 Leader 复制到 Follower 的,所以一旦 Leader 宕机,那些还没有来得及复制到 Follower 的消息还是会丢失。为了解决这个问题,Kafka 为生产者提供一个选项叫做 acks,当这个选项被设置为 all 时,生产者发送的每一条消息除了发给 Leader 外还会发给所有的 ISR,并且必须得到 Leader 和所有 ISR 的确认后才被认为发送成功。这样,只有 Leader 和所有的 ISR 都挂了,消息才会丢失。

NAME

从上面这张图来看,当设置 acks=all 时,需要同步执行 1,3,4 三个步骤,对于消息生产的性能来说也是有比较大的影响的,所以你在实际应用中需要仔细地权衡考量。 我给你的建议是:

  1. 如果你需要确保消息一条都不能丢失,那么建议不要开启消息队列的同步刷盘,而是需要使用集群的方式来解决,可以配置当所有 ISR Follower 都接收到消息才返回成功。
  2. 如果对消息的丢失有一定的容忍度,那么建议不部署集群,即使以集群方式部署,也建议配置只发送给一个 Follower 就可以返回成功了。
  3. 我们的业务系统一般对于消息的丢失有一定的容忍度,比如说以上面的红包系统为例,如果红包消息丢失了,我们只要后续给没有发送红包的用户补发红包就好了。

3. 在消费的过程中存在消息丢失的可能

我还是以 Kafka 为例来说明。一个消费者消费消息的进度是记录在消息队列集群中的,而消费的过程分为三步:接收消息、处理消息、更新消费进度。

这里面接收消息和处理消息的过程都可能会发生异常或者失败,比如说,消息接收时网络发生抖动,导致消息并没有被正确的接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,那么这条失败的消息就永远不会被处理了,也可以认为是丢失了。

所以,在这里你需要注意的是, 一定要等到消息接收和处理完成后才能更新消费进度,但是这也会造成消息重复的问题,比方说某一条消息在处理之后,消费者恰好宕机了,那么因为没有更新消费进度,所以当这个消费者重启之后,还会重复地消费这条消息。

如何保证消息只被消费一次

从上面的分析中,你能发现,为了避免消息丢失,我们需要付出两方面的代价:一方面是性能的损耗;一方面可能造成消息重复消费。

性能的损耗我们还可以接受,因为一般业务系统只有在写请求时才会有发送消息队列的操作,而一般系统的写请求的量级并不高,但是消息一旦被重复消费,就会造成业务逻辑处理的错误。那么我们要如何避免消息的重复呢?

想要完全的避免消息重复的发生是很难做到的,因为网络的抖动、机器的宕机和处理的异常都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使消费到了重复的消息,从消费的最终结果来看和只消费一次是等同的就好了,也就是保证在消息的生产和消费的过程是 幂等 的。

1. 什么是幂等

幂等是一个数学上的概念,它的含义是多次执行同一个操作和执行一次操作,最终得到的结果是相同的,说起来可能有些抽象,我给你举个例子:

比如,男生和女生吵架,女生抓住一个点不放,传递 你不在乎我了吗?(生产消息)的信息。那么当多次埋怨 你不在乎我了吗? 的时候(多次生产相同消息),她不知道的是,男生的耳朵(消息处理)会自动把 N 多次的信息屏蔽,就像只听到一次一样,这就是幂等性。

如果我们消费一条消息的时候,要给现有的库存数量减 1,那么如果消费两条相同的消息就会给库存数量减 2,这就不是幂等的。而如果消费一条消息后,处理逻辑是将库存的数量设置为 0,或者是如果当前库存数量是 10 时则减 1,这样在消费多条消息时,所得到的结果就是相同的, 这就是幂等的。

说白了,你可以这么理解幂等: 一件事儿无论做多少次都和做一次产生的结果是一样的,那么这件事儿就具有幂等性。

2. 在生产、消费过程中增加消息幂等性的保证

消息在生产和消费的过程中都可能会产生重复,所以你要做的是,在生产过程和消费过程中增加消息幂等性的保证,这样就可以认为从 最终结果上来看,消息实际上是只被消费了一次的。

在消息生产过程中, 在 Kafka0.11 版本和 Pulsar 中都支持 producer idempotency 的特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但是最终在消息队列存储时只会存储一份

它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致,就认为是重复的消息,服务端会自动丢弃。

笔者疑问:这里说得很模糊,光看含义,并不知道 kafka 怎么能给一个提交消息给一个 ID,我重复提交,还能识别重复了?和能保证唯一

NAME

而在消费端 , 幂等性的保证会稍微复杂一些,你可以从 通用层业务层 两个层面来考虑。

在通用层面,你可以在消息被生产的时候,使用发号器给它生成一个全局唯一的消息 ID,消息被处理之后,把这个 ID 存储在数据库中,在处理下一条消息之前,先从数据库里面查询这个全局 ID 是否被消费过,如果被消费过就放弃消费。

你可以看到,无论是生产端的幂等性保证方式,还是消费端通用的幂等性保证方式,它们的共同特点都是为每一个消息生成一个唯一的 ID,然后在使用这个消息的时候,先比对这个 ID 是否已经存在,如果存在,则认为消息已经被使用过。所以这种方式是一种标准的实现幂等的方式, 你在项目之中可以拿来直接使用, 它在逻辑上的伪代码就像下面这样:


boolean isIDExisted = selectByID(ID); // 判断 ID 是否存在
if(isIDExisted) {
  return; // 存在则直接返回
} else {
  process(message); // 不存在,则处理消息
  saveID(ID);   // 存储 ID
}

不过这样会有一个问题: 如果消息在处理之后,还没有来得及写入数据库,消费者宕机了重启之后发现数据库中并没有这条消息,还是会重复执行两次消费逻辑,这时你就需要引入事务机制,保证消息处理和写入数据库必须同时成功或者同时失败,但是这样消息处理的成本就更高了,所以,如果对于消息重复没有特别严格的要求,可以直接使用这种通用的方案,而不考虑引入事务。

在业务层面怎么处理呢? 这里有很多种处理方式,其中有一种是增加乐观锁的方式。比如,你的消息处理程序需要给一个人的账号加钱,那么你可以通过乐观锁的方式来解决。

具体的操作方式是这样的: 你给每个人的账号数据中增加一个版本号的字段,在生产消息时先查询这个账户的版本号,并且将版本号连同消息一起发送给消息队列。消费端在拿到消息和版本号后,在执行更新账户金额 SQL 的时候带上版本号,类似于执行:

update user set amount = amount + 20, version=version+1 where userId=1 and version=1;

你看,我们在更新数据时给数据加了乐观锁,这样在消费第一条消息时,version 值为 1,SQL 可以执行成功,并且同时把 version 值改为了 2;在执行第二条相同的消息时,由于 version 值不再是 1,所以这条 SQL 不能执行成功,也就保证了消息的幂等性。

课程小结

本节课,我主要带你了解了在消息队列中,消息可能会发生丢失的场景,和我们的应对方法,以及在消息重复的场景下,你要如何保证,尽量不影响消息最终的处理结果。我想强调的重点是:

  • 消息的丢失可以通过生产端的重试、消息队列配置集群模式,以及消费端合理处理消费进度三个方式来解决。
  • 为了解决消息的丢失通常会造成性能上的问题以及消息的重复问题。
  • 通过保证消息处理的幂等性可以解决消息的重复问题。

虽然我讲了很多应对消息丢失的方法,但并不是说消息丢失一定不能被接受,毕竟你可以看到,在允许消息丢失的情况下,消息队列的性能更好,方案实现的复杂度也最低。比如像是日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发的丢失几条日志是可以接受的。

所以方案设计看场景,这是一切设计的原则, 你不能把所有的消息队列都配置成防止消息丢失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外的负担。

4.19 - CH20-消息-延迟

学完前面两节课之后,相信你对在垂直电商项目中,如何使用消息队列应对秒杀时的峰值流量已经有所了解。当然了,你也应该知道要如何做,才能保证消息不会丢失,尽量避免消息重复带来的影响。 那么我想让你思考一下: 除了这些内容,你在使用消息队列时还需要关注哪些点呢?

先来看一个场景: 在你的垂直电商项目中,你会在用户下单支付之后,向消息队列里面发送一条消息,队列处理程序消费了消息后,会增加用户的积分,或者给用户发送优惠券。那么用户在下单之后,等待几分钟或者十几分钟拿到积分和优惠券是可以接受的,但是一旦消息队列出现大量堆积,用户消费完成后几小时还拿到优惠券,那就会有用户投诉了。

这时,你要关注的就是消息队列中, 消息的延迟 了,这其实是消费性能的问题,那么你要如何提升消费性能,保证更短的消息延迟呢? 在我看来, 你首先需要掌握如何来监控消息的延迟 ,因为有了数据之后,你才可以知道目前的延迟数据是否满足要求,也可以评估优化之后的效果。然后,你要掌握使用消息队列的正确姿势,以及关注消息队列本身是如何保证消息尽快被存储和投递的。

接下来,我们先来看看第一点:如何监控消息延迟。

如何监控消息延迟

在我看来,监控消息的延迟有两种方式:

  • 使用消息队列提供的工具,通过监控消息的堆积来完成;
  • 通过生成监控消息的方式来监控消息的延迟情况。

接下来,我带你实际了解一下。

假设在开篇的场景之下,电商系统中的消息队列已经堆积了大量的消息,那么你要想监控消息的堆积情况,首先需要从原理上了解, 在消息队列中消费者的消费进度是多少 ,因为这样才方便计算当前的消费延迟是多少。比方说,生产者向队列中一共生产了 1000 条消息,某一个消费者消费进度是 900 条,那么这个消费者的消费延迟就是 100 条消息。

在 Kafka 中,消费者的消费进度在不同的版本上是不同的。

在 Kafka0.9 之前的版本中,消费进度是存储在 ZooKeeper 中的,消费者在消费消息的时候,先要从 ZooKeeper 中获取最新的消费进度,再从这个进度的基础上消费后面的消息。

在 Kafka0.9 版本之后,消费进度被迁入到 Kakfa 的一个专门的 topic 叫 __consumer_offsets 里面。所以,如果你了解 kafka 的原理,你可以依据不同的版本,从不同的位置,获取到这个消费进度的信息。

当然,作为一个成熟的组件,Kafka 也提供了一些工具来获取这个消费进度的信息,帮助你实现自己的监控,这个工具主要有两个

kafka-consumer-groups.sh

首先,Kafka 提供了工具叫做 kafka-consumer-groups.sh(它在 Kafka 安装包的 bin 目录下)。

为了帮助你理解,我简单地搭建了一个 Kafka 节点,并且写入和消费了一些信息,然后我来使用命令看看消息累积情况,具体的命令如下:

./bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group test-consumer-group

结果如下:

NAME
  • TOPIC、PARITION:话题名和分区名;
  • CURRENT-OFFSET:当前消费者的消费进度;
  • LOG-END-OFFSET:当前生产消息的总数;
  • LAG:消费消息的堆积数(也就是 LOG-END-OFFSET - CURRENT-OFFSET)

通过这个命令你可以很方便地了解消费者的消费情况。

JMX

Kafka 通过 JMX 暴露了消息堆积的数据,我在本地启动了一个 console consumer,然后使用 jconsole 连接这个 consumer,你就可以看到这个 consumer 的堆积数据了(就是下图中红框里的数据)。这些数据你可以写代码来获取,这样也可以方便地输出到监控系统中, 我比较推荐这种方式。

NAME

除了使用消息队列提供的工具以外,你还可以通过生成监控消息的方式,来监控消息的延迟。 **具体怎么做呢?**生成监控消息

  1. 你先定义一种特殊的消息

  2. 然后启动一个监控程序,将这个消息定时地循环写入到消息队列中

    消息的内容可以是生成消息的时间戳,并且也会作为队列的消费者消费数据。

  3. 业务处理程序消费到这个消息时直接丢弃掉,而监控程序在消费到这个消息时, 就可以和这个消息的生成时间做比较 ,如果时间差达到某一个阈值就可以向我们报警。

NAME

这两种方式都可以监控消息的消费延迟情况, 而从我的经验出来,我比较推荐两种方式结合来使用。 比如在我的实际项目中,我会优先在监控程序中获取 JMX 中的队列堆积数据,做到 dashboard 报表中,同时也会启动探测进程,确认消息的延迟情况是怎样的。

在我看来,消息的堆积是对于消息队列的基础监控,这是你无论如何都要做的。但是,了解了消息的堆积情况,并不能很直观地了解消息消费的延迟,你也只能利用经验来确定堆积的消息量到了多少才会影响到用户的体验;而第二种方式对于消费延迟的监控则更加直观,而且从时间的维度来做监控也比较容易确定报警阈值。

了解了消息延迟的监控方式之后,我们再来看看如何提升消息的写入和消费性能,这样才会让异步的消息得到尽快的处理。

减少消息延迟的正确姿势

想要减少消息的处理延迟,我们需要在 消费端和消息队列 两个层面来完成。

在消费端,我们的目标是提升消费者的消息处理能力,你能做的是:

  • 优化消费代码提升性能;
  • 增加消费者的数量(这个方式比较简单)。

不过,第二种方式会受限于消息队列的实现。比如说,如果消息队列使用的是 Kafka 就无法通过增加消费者数量的方式,来提升消息处理能力。

因为在 Kafka 中,一个 Topic(话题)可以配置多个 Partition(分区),数据会被平均或者按照生产者指定的方式,写入到多个分区中,那么在消费的时候,Kafka 约定一个分区只能被一个消费者消费,为什么要这么设计呢?在我看来,如果有多个 consumer(消费者)可以消费一个分区的数据,那么在操作这个消费进度的时候就需要加锁,可能会对性能有一定的影响。

所以说,话题的分区数量决定了消费的并行度,增加多余的消费者也是没有用处的,那么你可以通过增加分区来提高消费者的处理能力。

NAME

那么,如何在不增加分区的前提下提升消费能力呢?

既然不能增加 consumer,那么你可以在一个 consumer 中提升处理消息的并行度,所以可以考虑使用多线程的方式来增加处理能力:你可以预先创建一个或者多个线程池,在接收到消息之后,把消息丢到线程池中来异步地处理,这样,原本串行的消费消息的流程就变成了并行的消费,可以提高消息消费的吞吐量,在并行处理的前提下,我们就可以在一次和消息队列的交互中多拉取几条数据,然后分配给多个线程来处理。

NAME

另外,你在消费队列中数据的时候还需要注意消费线程空转的问题。

我是最初在测试自己写的一个消息中间件的时候发现的。 当时,我发现运行消费客户端的进程会偶发地出现 CPU 跑满的情况,于是打印了 JVM 线程堆栈,找到了那个跑满 CPU 的线程。这个时候才发现,原来是消息队列中,有一段时间没有新的消息,于是消费客户端拉取不到新的消息就会不间断地轮询拉取消息,这个线程就把 CPU 跑满了。

所以,你在写消费客户端的时候要考虑这种场景,拉取不到消息可以等待一段时间再来拉取,等待的时间不宜过长,否则会增加消息的延迟。我一般建议固定的 10ms~100ms,也可以按照一定步长递增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最长可以到 100ms,直到拉取到消息再回到 10ms。

说完了消费端的做法之后, 再来说说消息队列本身在读取性能优化方面做了哪些事情。

我曾经也做过一个消息中间件,在最初设计中间件的时候,我主要从两方面考虑读取性能问题:

  • 消息的存储;
  • 零拷贝技术。

针对第一点, 我最初在设计的时候为了实现简单,使用了普通的数据库来存储消息,但是受限于数据库的性能瓶颈,读取 QPS 只能到 2000,后面我重构了存储模块,使用本地磁盘作为存储介质。Page Cache 的存在就可以提升消息的读取速度,即使要读取磁盘中的数据,由于消息的读取是顺序的,并且不需要跨网络读取数据,所以读取消息的 QPS 提升了一个数量级。

另外一个优化点是零拷贝技术, 说是零拷贝,其实,我们不可能消灭数据的拷贝,只是尽量减少拷贝的次数。在读取消息队列的数据的时候,其实就是把磁盘中的数据通过网络发送给消费客户端,在实现上会有四次数据拷贝的步骤:

  1. 数据从磁盘拷贝到内核缓冲区;
  2. 系统调用将内核缓存区的数据拷贝到用户缓冲区;
  3. 用户缓冲区的数据被写入到 Socket 缓冲区中;
  4. 操作系统再将 Socket 缓冲区的数据拷贝到网卡的缓冲区中。
NAME

操作系统提供了 Sendfile 函数,可以减少数据被拷贝的次数。使用了 Sendfile 之后,在内核缓冲区的数据不会被拷贝到用户缓冲区,而是直接被拷贝到 Socket 缓冲区,节省了一次拷贝的过程,提升了消息发送的性能。高级语言中对于 Sendfile 函数有封装,比如说在 Java 里面的 java.nio.channels.FileChannel 类就提供了 transferTo 方法提供了 Sendfile 的功能。

NAME

课程小结

本节课我带你了解了,如何提升消息队列的性能来降低消息消费的延迟,这里我想让你明确的重点是:

  • 我们可以使用消息队列提供的工具,或者通过发送监控消息的方式,来监控消息的延迟情况;
  • 横向扩展消费者是提升消费处理能力的重要方式;
  • 选择高性能的数据存储方式,配合零拷贝技术,可以提升消息的消费性能。

其实,队列是一种常用的组件,只要涉及到队列,任务的堆积就是一个不可忽视的问题, 我遇到过的很多故障都是源于此。

比如说,前一段时间处理的一个故障,前期只是因为数据库性能衰减有少量的慢请求,结果这些慢请求占满了 Tomcat 线程池,导致整体服务的不可用。如果我们能对 Tomcat 线程池的任务堆积情况有实时地监控,或者说对线程池有一些保护策略,比方说线程全部使用之后丢弃请求,也许就会避免故障的发生。在此,我希望你在实际的工作中能够引以为戒,只要有队列就要监控它的堆积情况,把问题消灭在萌芽之中。

4.20 - CH21-分布式-服务拆分

通过前面几个篇章的内容,你已经从数据库、缓存和消息队列的角度对自己的垂直电商系统在性能、可用性和扩展性上做了优化。

现在,你的系统运行稳定,好评不断,每天高峰期的流量,已经达到了 10000/s 请求,DAU((Daily Active User)日活跃用户数量) 也涨到了几十万。CEO 非常高兴,打算继续完善产品功能,以便进行新一轮的运营推广,争取在下个双十一可以将 DAU 冲击过百万。这时,你开始考虑,怎么通过技术上的优化改造,来支撑更高的并发流量,比如支撑过百万的 DAU。

于是,你重新审视了自己的系统架构,分析系统中有哪些可以优化的点。

NAME

目前来看,工程的部署方式还是采用一体化架构,也就是说所有的功能模块,比方说电商系统中的订单模块、用户模块、支付模块、物流模块等等,都被打包到一个大的 Web 工程中,然后部署在应用服务器上。

你隐约觉得这样的部署方式可能存在问题,于是,你 Google 了一下,发现当系统发展到一定阶段,都要做微服务化的拆分,你也看到淘宝的「五彩石」项目,对于淘宝整体架构的扩展性,带来的巨大影响。这一切让你心驰神往。

但是有一个问题一直萦绕在你的心里:究竟是什么促使我们将一体化架构,拆分成微服务化架构?是不是说系统的整体 QPS 到了 1 万,或者到了 2 万,就一定要做微服务化拆分呢?

一体化架构的痛点

先来回想一下,你当初为什么选用了一体化架构。

在电商项目刚刚启动的时候,你只是希望能够尽量快地将项目搭建起来,方便将产品更早地投放市场,快速完成验证。

在系统开发的初期,这种架构确实给你的开发运维,带来了很大的便捷,主要体现在:

  • 开发简单直接,代码和项目集中式管理;
  • 只需要维护一个工程,节省维护系统运行的人力成本;
  • 排查问题的时候,只需要排查这个应用进程就可以了,目标性强。

但随着功能越来越复杂,开发团队规模越来越大,你慢慢感受到了一体化架构的一些缺陷,这主要体现在以下几个方面。

首先, 在技术层面上,数据库连接数可能成为系统的瓶颈。

在第 7 讲中我提到,数据库的连接是比较重的一类资源,不仅连接过程比较耗时,而且连接 MySQL 的客户端数量有限制, 最多可以设置为 16384 (在实际的项目中,可以依据实际业务来调整)。

这个数字看着很大,但是因为你的系统是按照一体化架构部署的,在部署结构上没有分层,应用服务器直接连接数据库,那么当前端请求量增加,部署的应用服务器扩容,数据库的连接数也会大增,给你举个例子。

我之前维护的一个系统中, 数据库的最大连接数设置为 8000,应用服务器部署在虚拟机上,数量大概是 50 个,每个服务器会和数据库建立 30 个连接,但是数据库的连接数,却远远大于 30 * 50 = 1500。

因为你不仅要支撑来自客户端的外网流量,还要部署单独的应用服务,支撑来自其它部门的内网调用,也要部署队列处理机,处理来自消息队列的消息,这些服务也都是与数据库直接连接的,林林总总加起来,在高峰期的时候,数据库的连接数要接近 3400。

所以,一旦遇到一些大的运营推广活动,服务器就要扩容,数据库连接数也随之增加,基本上就会处在最大连接数的边缘。这就像一颗定时炸弹,随时都会影响服务的稳定。

第二点, 一体化架构增加了研发的成本,抑制了研发效率的提升。

《人月神话》中曾经提到:一个团队内部沟通成本,和人员数量 n 有关,约等于 n(n-1)/2,也就是说随着团队人员的增加,沟通的成本呈指数级增长,一个 100 人的团队,需要沟通的渠道大概是 100(100-1)/2 = 4950。那么为了减少沟通成本,我们一般会把团队拆分成若干个小团队,每个小团队 5~7 人,负责一部分功能模块的开发和维护。

比方说,你的垂直电商系统团队就会被拆分为用户组、订单组、支付组、商品组等等。当如此多的小团队共同维护一套代码,和一个系统时,在配合时就会出现问题。

不同的团队之间沟通少,假如一个团队需要一个发送短信的功能,那么有的研发同学会认为最快的方式,不是询问其他团队是否有现成的,而是自己写一套,但是这种想法是不合适的,这样一来就会造成功能服务的重复开发。

由于代码部署在一起,每个人都向同一个代码库提交代码,代码冲突无法避免;同时,功能之间耦合严重,可能你只是更改了很小的逻辑,却导致其它功能不可用,从而在测试时需要对整体功能回归,延长了交付时间。

模块之间互相依赖,一个小团队中的成员犯了一个错误,就可能会影响到,其它团队维护的服务,对于整体系统稳定性影响很大。

第三点,一体化架构对于系统的运维也会有很大的影响。

想象一下,在项目初期,你的代码可能只有几千行,构建一次只需要一分钟,那么你可以很敏捷灵活地频繁上线变更修复问题。但是当你的系统扩充到几十万行,甚至上百万行代码的时候,一次构建的过程,包括编译、单元测试、打包和上传到正式环境,花费的时间可能达到十几分钟,并且,任何小的修改,都需要构建整个项目,上线变更的过程非常不灵活。

而我说的这些问题,都可以通过微服务化拆分来解决。

如何使用微服务化解决这些痛点

之前,我在做一个社区业务的时候,开始采用的架构也是一体化的架构,数据库已经做了垂直分库,分出了用户库、内容库和互动库,并且已经将工程拆分了业务池,拆分成了用户池、内容池和互动池。

当前端的请求量越来越大时,我们发现,无论哪个业务池子,用户模块都是请求量最大的模块儿,用户库也是请求量最大的数据库。这很好理解,无论是内容还是互动,都会查询用户库获取用户数据,所以,即使我们做了业务池的拆分,但实际上,每一个业务池子都需要连接用户库,并且请求量都很大,这就造成了用户库的连接数比其它都要多一些,容易成为系统的瓶颈。

NAME

那么我们怎么解决这个问题呢?

其实,可以把与用户相关的逻辑,部署成一个单独的服务,其它无论是用户池、内容池还是互动池,都连接这个服务来获取和更改用户信息,那么也就是说,只有这个服务可以连接用户库,其它的业务池都不直连用户库获取数据。

由于这个服务只处理和用户相关的逻辑,所以,不需要部署太多的实例就可以承担流量,这样就可以有效地控制用户库的连接数,提升了系统的可扩展性。那么如此一来,我们也可以将内容和互动相关的逻辑,都独立出来,形成内容服务和互动服务,这样,我们就通过 按照业务做横向拆分 的方式,解决了数据库层面的扩展性问题。

NAME

再比如,我们在做社区业务的时候,会有多个模块需要使用地理位置服务,将 IP 信息或者经纬度信息,转换为城市信息。比如,推荐内容的时候,可以结合用户的城市信息,做附近内容的推荐;展示内容信息的时候,也需要展示城市信息等等。

那么,如果每一个模块都实现这么一套逻辑就会导致代码不够重用。因此,我们可以把将 IP 信息或者经纬度信息,转换为城市信息,包装成单独的服务供其它模块调用,也就是, 我们可以将与业务无关的公用服务抽取出来,下沉成单独的服务。

按照以上两种拆分方式将系统拆分之后,每一个服务的功能内聚,维护人员职责明确,增加了新的功能只需要测试自己的服务就可以了,而一旦服务出了问题,也可以通过服务熔断、降级的方式减少对于其他服务的影响(我会在第 34 讲中系统地讲解)。

另外,由于每个服务都只是原有系统的子集,代码行数相比原有系统要小很多,构建速度上也会有比较大的提升。

当然,微服务化之后,原有单一系统被拆分成多个子服务,无论在开发,还是运维上都会引入额外的问题,那么这些问题是什么? 我们将如何解决呢?下一节课,我会带你来了解。

课程小结

本节课,我主要带你了解了,实际业务中会基于什么样的考虑,对系统做微服务化拆分,其实,系统的 QPS 并不是决定性的因素。影响的因素,我归纳为以下几点:

  • 系统中,使用的资源出现扩展性问题,尤其是数据库的连接数出现瓶颈;
  • 大团队共同维护一套代码,带来研发效率的降低,和研发成本的提升;
  • 系统部署成本越来越高。

4.21 - CH22-分布式-微服务

上一节课,我带你了解了,单体架构向微服务化架构演进的原因,你应该了解到,当系统依赖资源的扩展性出现问题,或者是一体化架构带来的研发成本、部署成本变得难以接受时,我们会考虑对整体系统,做微服务化拆分。

微服务化之后, 垂直电商系统的架构会将变成下面这样:

NAME

在这个架构中,我们将用户、订单和商品相关的逻辑,抽取成服务独立的部署,原本的 Web 工程和队列处理程序,将不再直接依赖缓存和数据库,而是通过调用服务接口,查询存储中的信息。

有了构思和期望之后,为了将服务化拆分尽快落地,你们决定抽调主力研发同学,共同制定拆分计划。但是细致讨论后发现,虽然对服务拆分有了大致的方向,可还是有很多疑问,比如:

  • 服务拆分时要遵循哪些原则?
  • 服务的边界如何确定?服务的粒度是怎样呢?
  • 在服务化之后,会遇到哪些问题呢?我们又将如何来解决?

当然,你也许想知道,微服务拆分的具体操作过程和步骤是怎样的,但是这部分内容涉及的知识点比较多,不太可能在一次课程中,把全部内容涵盖到。而且《DDD 实战课》中,已经侧重讲解了微服务化拆分的具体过程,你可以借鉴。

上面这三点内容,会影响服务化拆分的效果,但在实际的项目中,经常被大部分人忽略,所以是我们本节课的重点内容。而我希望你能把本节课的内容和自身的业务结合起来体会,思考业务服务化拆分的方式和方法。

微服务拆分的原则

之前,你维护的一体化架构,就像是一个大的蜘蛛网,不同功能模块,错综复杂地交织在一起,方法之间调用关系非常的复杂,导致你修复了一个 Bug,可能会引起另外多个 Bug,整体的维护成本非常高。同时,数据库较弱的扩展性,也限制了服务的扩展能力

出于上述考虑, 你要对架构做拆分。但拆分并不像听上去那么简单,这其实就是将整体工程,重构甚至重写的过程。你需要将代码,拆分到若干个子工程里面,再将这些子工程,通过一些通信方式组装起来,这对架构是很大的调整,需要跨多个团队协调完成。

所以在开始拆分之前,你需要明确几个拆分的原则,否则就会事倍功半,甚至对整体项目产生不利的影响。

原则一, 做到单一服务内部功能的高内聚,和低耦合。也就是说,每个服务只完成自己职责之内的任务,对于不是自己职责的功能,交给其它服务来完成。说起来你可能觉得理所当然,对这一点不屑一顾,但很多人在实际开发中,经常会出现一些问题。

比如,我之前的项目中, 有用户服务和内容服务,用户信息中有「是否为认证用户」字段。组内有个同学在内容服务里有这么一段逻辑:如果用户认证字段等于 1,代表是认证用户,那么就把内容权重提升。问题是,判断用户是否为认证用户的逻辑,应该内聚在用户服务内部,而不应该由内容服务判断,否则认证的逻辑一旦变更,内容服务也需要一同跟着变更,这就不满足高内聚、低耦合的要求了。所幸,我们在 Review 代码时,及时发现了这个问题,并在服务上线之前修复了它。

原则二, 你需要关注服务拆分的粒度,先粗略拆分,再逐渐细化。在服务拆分的初期,你其实很难确定,服务究竟要拆分成什么样。但是,从「微服务」这几个字来看,服务的粒度貌似应该足够小,甚至有「一方法一服务」的说法。不过,服务多了也会带来问题,像是服务个数的增加会增加运维的成本。再比如,原本一次请求只需要调用进程内的多个方法,现在则需要跨网络调用多个 RPC 服务,在性能上肯定会有所下降。

所以我推荐的做法是: 拆分初期可以把服务粒度拆的粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。 比如说, 对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。

原则三, 拆分的过程,要尽量避免影响产品的日常功能迭代,也就是说,要一边做产品功能迭代,一边完成服务化拆分。

还是拿我之前维护的一个项目举例。 我曾经在竞品对手快速发展的时期做了服务的拆分,拆分的方式是停掉所有业务开发,全盘推翻重构,结果错失了产品发展的最佳机会,最终败给了竞争对手。因此,我们的拆分只能在现有一体化系统的基础上,不断剥离业务独立部署, 剥离的顺序,你可以参考以下几点:

  1. 优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发,减少拆分对现有业务的影响,也给团队一个练习、试错的机会;

  2. 当两个服务存在依赖关系时,优先拆分被依赖的服务

    比方说,内容服务依赖于用户服务获取用户的基本信息,那么如果先把内容服务拆分出来,内容服务就会依赖于一体化架构中的用户模块,这样还是无法保证内容服务的快速部署能力。

所以正确的做法是, 你要理清服务之间的调用关系,比如说,内容服务会依赖用户服务获取用户信息,互动服务会依赖内容服务,所以要按照先用户服务,再内容服务,最后互动服务的顺序来进行拆分。

原则四, 服务接口的定义要具备可扩展性。服务拆分之后,由于服务是以独立进程的方式部署,所以服务之间通信,就不再是进程内部的方法调用,而是跨进程的网络通信了。在这种通信模型下需要注意,服务接口的定义要具备可扩展性,否则在服务变更时,会造成意想不到的错误。

在之前的项目中, 某一个微服务的接口有三个参数,在一次业务需求开发中,组内的一个同学将这个接口的参数调整为了四个,接口被调用的地方也做了修改,结果上线这个服务后,却不断报错,无奈只能回滚。

想必你明白了,这是因为这个接口先上线后,参数变更成了四个,但是调用方还未变更,还是在调用三个参数的接口,那就肯定会报错了。所以,服务接口的参数类型最好是封装类,这样如果增加参数,就不必变更接口的签名,而只需要在类中添加字段即就可以了。

微服务化带来的问题和解决思路

那么,依据这些原则,将系统做微服务拆分之后,是不是就可以一劳永逸,解决所有问题了呢?当然不是。

微服务化只是一种架构手段,有效拆分后,可以帮助实现服务的敏捷开发和部署。但是,由于将原本一体化架构的应用,拆分成了,多个通过网络通信的分布式服务,为了在分布式环境下,协调多个服务正常运行,就必然引入一定的复杂度,这些复杂度主要体现在以下几个方面:

  1. 服务接口的调用,不再是同一进程内的方法调用,而是跨进程的网络调用,这会增加接口响应时间的增加。

    此时,我们就要选择高效的服务调用框架,同时,接口调用方需要知道服务部署在哪些机器的哪个端口上,这些信息需要存储在一个分布式一致性的存储中, 于是就需要引入服务注册中心, 这一点,是我在 24 讲会提到的内容。 不过在这里我想强调的是, 注册中心管理的是服务完整的生命周期,包括对于服务存活状态的检测。

  2. 多个服务之间有着错综复杂的依赖关系。

    一个服务会依赖多个其它服务,也会被多个服务所依赖,那么一旦被依赖的服务的性能出现问题,产生大量的慢请求,就会导致依赖服务的工作线程池中的线程被占满,那么依赖的服务也会出现性能问题。接下来,问题就会沿着依赖网,逐步向上蔓延,直到整个系统出现故障为止。

    为了避免这种情况的发生, 我们需要引入服务治理体系, 针对出问题的服务,采用熔断、降级、限流、超时控制的方法,使得问题被限制在单一服务中,保护服务网络中的其它服务不受影响。

  3. 服务拆分到多个进程后,一条请求的调用链路上,涉及多个服务,那么一旦这个请求的响应时间增长,或者是出现错误,我们就很难知道,是哪一个服务出现的问题。另外,整体系统一旦出现故障,很可能外在的表现是所有服务在同一时间都出现了问题,你在问题定位时,很难确认哪一个服务是源头,这就需要 引入分布式追踪工具,以及更细致的服务端监控报表。

我在 25 讲和 30 讲会详细的剖析这个内容, 在这里我想强调的是:

  • 监控报表关注的是,依赖服务和资源的宏观性能表现;
  • 分布式追踪关注的是,单一慢请求中的性能瓶颈分析

两者需要结合起来帮助你来排查问题。

以上这些微服务化后,在开发方面引入的问题,就是接下来,分布式服务篇和维护篇的主要讨论内容。

总的来说,微服务化是一个很大的话题,在微服务开发和维护时,你也许会在很短时间就把微服务拆分完成,但是你可能会花相当长的时间来完善服务治理体系。接下来的内容,会涉及一些常用微服务中间件的原理,和使用方式,你可以使用以下的方式更好地理解后面的内容:

  • 快速完成中间件的部署运行,建立对它感性的认识;
  • 阅读它的文档中,基本原理和架构设计部分;
  • 必要时,阅读它的源码,加深对它的理解,这样可以帮助你在维护你的微服务时,排查中间件引起的故障和解决性能问题。

课程小结

本节课,为了能够指导你更好地进行服务化的拆分,我带你了解了,微服务化拆分的原则,内容比较清晰。在这里,我想延伸一些内容:

  1. 「康威定律」提到,设计系统的组织,其产生的设计等同于组织间的沟通结构。通俗一点说,就是你的团队组织结构是什么样的,你的架构就会长成什么样。

    如果你的团队分为服务端开发团队,DBA 团队,运维团队,测试团队,那么你的架构就是一体化的,所有的团队共同为一个大系统负责,团队内成员众多,沟通成本就会很高;而如果你想实现微服务化的架构, 那么你的团队也要按照业务边界拆分, 每一个模块由一个自治的小团队负责,这个小团队里面有开发、测试、运维和 DBA,这样沟通就只发生在这个小团队内部,沟通的成本就会明显降低。

  2. 微服务化的一个目标是减少研发的成本,其中也包括沟通的成本,所以小团队内部成员不宜过多。

    按照亚马逊 CEO,贝佐斯的「两个披萨」的理论,如果两个披萨不够你的团队吃,那么你的团队就太大了,需要拆分,所以一个小团队包括开发、运维、测试以 6~8 个人为最佳;

  3. 如果你的团队人数不多,还没有做好微服务化的准备,而你又感觉到研发和部署的成本确实比较高,那么一个折中的方案是, 你可以优先做工程的拆分。

    比如说,如果你使用的是 Java 语言,你可以依据业务的边界,将代码拆分到不同的子工程中,然后子工程之间以 jar 包的方式依赖,这样每个子工程代码量减少,可以减少打包时间;并且子工程代码内部,可以做到高内聚低耦合,一定程度上减少研发的成本,也不失为一个不错的保守策略。

4.22 - CH23-分布式-RPC

在 21 讲和 22 讲中,你的团队已经决定对垂直电商系统做服务化拆分,以便解决扩展性和研发成本高的问题。与此同时,你们在不断学习的过程中还发现,系统做了服务化拆分之后,会引入一些新的问题,这些问题我在上节课提到过,归纳起来主要是两点:

  • 服务拆分单独部署后,引入的服务跨网络通信的问题;
  • 在拆分成多个小服务之后,服务如何治理的问题。

如果想要解决这两方面问题,你需要了解,微服务化所需要的中间件的基本原理,和使用技巧,那么本节课,我会带你掌握,解决第一点问题的核心组件: RPC 框架。

来思考这样一个场景: 你的垂直电商系统的 QPS 已经达到了每秒 2 万次,在做了服务化拆分之后,由于我们把业务逻辑,都拆分到了单独部署的服务中,那么假设你在完成一次完整的请求时,需要调用 4~5 次服务,计算下来,RPC 服务需要承载大概每秒 10 万次的请求。那么,你该如何设计 RPC 框架,来承载如此大的请求量呢?你要做的是:

  • 选择合适的网络模型,有针对性地调整网络参数,以优化网络传输性能;
  • 选择合适的序列化方式,以提升封包、解包的性能。

接下来,我从原理出发,让你对于 RPC 有一个理性的认识,这样你在设计 RPC 框架时,就可以清晰地知道自己的设计目标是什么了。

你所知道的 RPC

说到 RPC(Remote Procedure Call,远程过程调用),你不会陌生,它指的是通过网络,调用另一台计算机上部署服务的技术。

而 RPC 框架就封装了网络调用的细节,让你像调用本地服务一样,调用远程部署的服务。你也许觉得只有像 Dubbo、Grpc、Thrift 这些新兴的框架才算是 RPC 框架, 其实严格来说,你很早之前就接触到与 RPC 相关的技术了。

比如,Java 原生就有一套远程调用框架 叫做 RMI(Remote Method Invocation), 它可以让 Java 程序通过网络,调用另一台机器上的 Java 对象的方法。它是一种远程调用的方法,也是 J2EE 时代大名鼎鼎的 EJB 的实现基础。

时至今日,你仍然可以通过 Spring 的 RmiServiceExporter 将 Spring 管理的 bean 暴露成一个 RMI 的服务,从而继续使用 RMI 来实现跨进程的方法调用。之所以 RMI 没有像 Dubbo,Grpc 一样大火, 是因为它存在着一些缺陷:

  • RMI 使用专为 Java 远程对象定制的协议 JRMP(Java Remote Messaging Protocol)进行通信,这限制了它的通信双方,只能是 Java 语言的程序,无法实现跨语言通信;
  • RMI 使用 Java 原生的对象序列化方式,生成的字节数组空间较大,效率很差。

另一个你可能听过的技术是 Web Service, 它也可以认为是 RPC 的一种实现方式。它的优势是,使用 HTTP+SOAP 协议,保证了调用可以跨语言,跨平台。只要你支持 HTTP 协议,可以解析 XML,那么就能够使用 Web Service。在我来看,它由于使用 XML 封装数据,数据包大,性能还是比较差。

借上面几个例子,我主要是想告诉你, RPC 并不是互联网时代的产物,也不是服务化之后才衍生出来的技术,而是一种规范,只要是封装了网络调用的细节,能够实现远程调用其他服务,就可以算作是一种 RPC 技术了。

那么你的垂直电商项目在使用 RPC 框架之后, 会产生什么变化呢?

在我来看,在性能上的变化是不可忽视的, 我给你举个例子。 比方说,你的电商系统中,商品详情页面需要商品数据、评论数据还有店铺数据,如果在一体化的架构中,你只需要从商品库,评论库和店铺库获取数据就可以了,不考虑缓存的情况下有三次网络请求。

但是,如果独立出商品服务、评论服务和店铺服务之后,那么就需要分别调用这三个服务,而这三个服务又会分别调用各自的数据库,这就是六次网络请求。如果你服务拆分的更细粒度,那么多出的网络调用就会越多,请求的延迟就会更长,而这就是你为了提升系统的扩展性,在性能上所付出的代价。

NAME

那么,我们要如果优化 RPC 的性能,从而尽量减少网络调用,对于性能的影响呢?在这里,你首先需要了解一次 RPC 的调用都经过了哪些步骤,因为这样,你才可以针对这些步骤中可能存在的性能瓶颈点提出优化方案。 步骤如下:

  • 在 一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流;
  • 然后客户端将二进制流,通过网络发送给服务端;
  • 服务端接收到二进制流之后,将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式,调用对应的方法得到返回值;
  • 服务端将返回值序列化,再通过网络发送给客户端;
  • 客户端对结果反序列化之后,就可以得到调用的结果了。

过程图如下:

NAME

从这张图中你可以看到,有网络传输的过程,也有将请求序列化和反序列化的过程, 所以,如果要提升 RPC 框架的性能,需要从 网络传输和序列化 两方面来优化。

如何提升网络传输性能

在网络传输优化中,你首要做的,是选择一种高性能的 I/O 模型。所谓 I/O 模型,就是我们处理 I/O 的方式。而一般单次 I/O 请求会分为两个阶段,每个阶段对于 I/O 的处理方式是不同的。

首先,I/O 会经历一个等待资源的阶段, 比方说,等待网络传输数据可用,在这个过程中我们对 I/O 会有两种处理方式:

  • 阻塞。指的是在数据不可用时,I/O 请求一直阻塞,直到数据返回;
  • 非阻塞。指的是数据不可用时,I/O 请求立即返回,直到被通知资源可用为止。

然后是使用资源的阶段, 比如说从网络上接收到数据,并且拷贝到应用程序的缓冲区里面。在这个阶段我们也会有两种处理方式:

  • 同步处理。指的是 I/O 请求在读取或者写入数据时会阻塞,直到读取或者写入数据完成;
  • 异步处理。指的是 I/O 请求在读取或者写入数据时立即返回,当操作系统处理完成 I/O 请求,并且将数据拷贝到用户提供的缓冲区后,再通知应用 I/O 请求执行完成。

将这两个阶段的四种处理方式,做一些排列组合,再做一些补充,就得到了我们常见的五种 I/O 模型:

  • 同步阻塞 I/O
  • 同步非阻塞 I/O
  • 同步多路 I/O 复用
  • 信号驱动 I/O
  • 异步 I/O

这五种 I/O 模型,你需要理解它们的区别和特点,不过在理解上你可能会有些难度,所以我来做个比喻,方便你理解。

我们来把 I/O 过程比喻成烧水倒水的过程,等待资源(就是烧水的过程),使用资源(就是倒水的过程):

  • 如果你站在炤台边上一直等着(等待资源)水烧开,然后倒水(使用资源),那么就是同步阻塞 I/O;
  • 如果你偷点儿懒,在烧水的时候躺在沙发上看会儿电视(不再时时刻刻等待资源),但是还是要时不时的去看看水开了没有,一旦水开了,马上去倒水(使用资源),那么这就是同步非阻塞 I/O;
  • 如果你想要洗澡,需要同时烧好多壶水,那你就在看电视的间隙去看看哪壶水开了(等待多个资源),哪一壶开了就先倒哪一壶,这样就加快了烧水的速度,这就是同步多路 I/O 复用;
  • 不过你发现自己总是跑厨房去看水开了没,太累了,于是你考虑给你的水壶加一个报警器(信号),只要水开了就马上去倒水,这就是信号驱动 I/O;
  • 最后一种就高级了,你发明了一个智能水壶,在水烧好后自动就可以把水倒好,这就是异步 I/O。

这五种 I/O 模型中最被广泛使用的是 多路 I/O 复用, Linux 系统中的 select、epoll 等系统调用都是支持多路 I/O 复用模型的,Java 中的高性能网络框架 Netty 默认也是使用这种模型。所以,我们可以选择它。

那么,选择好了一种高性能的 I/O 模型,是不是就能实现,数据在网络上的高效传输呢?其实并没有那么简单,网络性能的调优涉及很多方面, 其中不可忽视的一项就是网络参数的调优, 接下来,我带你了解其中一个典型例子。当然,你可以结合网络基础知识,以及成熟 RPC 框架(比如 Dubbo)的源码来深入了解,网络参数调优的方方面面。

在之前的项目中, 我的团队曾经写过一个简单的 RPC 通信框架。在进行测试的时候发现,远程调用一个空业务逻辑的方法时,平均响应时间居然可以到几十毫秒,这明显不符合我们的预期,在我们看来,运行一个空的方法,应该在 1 毫秒之内可以返回。于是,我先在测试的时候使用 tcpdump 抓了包,发现一次请求的 Ack 包竟然要经过 40ms 才返回。在网上 google 了一下原因,发现原因和一个叫做 tcp_nodelay 的参数有关。 这个参数是什么作用呢?

tcp 协议的包头有 20 字节,ip 协议的包头也有 20 字节,如果仅仅传输 1 字节的数据,在网络上传输的就有 20 + 20 + 1 = 41 字节,其中真正有用的数据只有 1 个字节,这对效率和带宽是极大的浪费。所以在 1984 年的时候,John Nagle 提出了以他的名字命名的 Nagle`s 算法, 他期望:

如果是连续的小数据包,大小没有一个 MSS(Maximum Segment Size,最大分段大小),并且还没有收到之前发送的数据包的 Ack 信息,那么这些小数据包就会在发送端暂存起来,直到小数据包累积到一个 MSS,或者收到一个 Ack 为止。

这原本是为了减少不必要的网络传输,但是如果接收端开启了 DelayedACK(延迟 ACK 的发送,这样可以合并多个 ACK,提升网络传输效率),那就会发生, 发送端发送第一个数据包后,接收端没有返回 ACK,这时发送端发送了第二个数据包,因为 Nagle`s 算法的存在,并且第一个发送包的 ACK 还没有返回,所以第二个包会暂存起来。而 DelayedACK 的超时时间,默认是 40ms,所以一旦到了 40ms,接收端回给发送端 ACK,那么发送端才会发送第二个包, 这样就增加了延迟。

解决的方式非常简单: 只要在 socket 上开启 tcp_nodelay 就好了,这个参数关闭了 Nagle`s 算法,这样发送端就不需要等到上一个发送包的 ACK 返回,直接发送新的数据包就好了。这对于强网络交互的场景来说非常的适用,基本上,如果你要自己实现一套网络框架,tcp_nodelay 这个参数最好是要开启的。

选择合适的序列化方式

在对网络数据传输完成调优之后,另外一个需要关注的点就是, 数据的序列化和反序列化。 通常所说的序列化,是将传输对象转换成二进制串的过程,而反序列化则是相反的动作,是将二进制串转换成对象的过程。

从上面的 RPC 调用过程中你可以看到,一次 RPC 调用需要经历两次数据序列化的过程,和两次数据反序列化的过程,可见它们对于 RPC 的性能影响是很大的, 那么我们在选择序列化方式的时候需要考虑哪些因素呢?

首先需要考虑的肯定是性能嘛,性能包括时间上的开销和空间上的开销,时间上的开销就是序列化和反序列化的速度,这是显而易见需要重点考虑的,而空间上的开销则是序列化后的二进制串的大小,过大的二进制串也会占据传输带宽,影响传输效率。

除去性能之外,我们需要考虑的是它是否可以跨语言,跨平台,这一点也非常重要,因为一般的公司的技术体系都不是单一的,使用的语言也不是单一的,那么如果你的 RPC 框架中传输的数据只能被一种语言解析,那么这无疑限制了框架的使用。

另外,扩展性也是一个需要考虑的重点问题。你想想,如果对象增加了一个字段就会造成传输协议的不兼容,导致服务调用失败,这会是多么可怕的事情。

综合上面的几个考虑点,在我看来, 我们的序列化备选方案主要有以下几种:

首先是大家熟知的 JSON,它起源于 JavaScript,是一种最广泛使用的序列化协议,它的优势简单易用,人言可读,同时在性能上相比 XML 有比较大的优势。

另外的 Thrift 和 Protobuf 都是需要引入 IDL(Interface description language)的,也就是需要按照约定的语法写一个 IDL 文件,然后通过特定的编译器将它转换成各语言对应的代码,从而实现跨语言的特点。

Thrift 是 Facebook 开源的高性能的序列化协议,也是一个轻量级的 RPC 框架;Protobuf 是谷歌开源的序列化协议。它们的共同特点是,无论在空间上还是时间上都有着很高的性能,缺点就是由于 IDL 存在带来一些使用上的不方便。

那么,你要如何选择这几种序列化协议呢? 这里我给你几点建议:

  • 如果对于性能要求不高,在传输数据占用带宽不大的场景下,可以使用 JSON 作为序列化协议;
  • 如果对于性能要求比较高,那么使用 Thrift 或者 Protobuf 都可以。而 Thrift 提供了配套的 RPC 框架,所以想要一体化的解决方案,你可以优先考虑 Thrift;
  • 在一些存储的场景下,比如说你的缓存中存储的数据占用空间较大,那么你可以考虑使用 Protobuf 替换 JSON,作为存储数据的序列化方式。

课程小结

为了优化 RPC 框架的性能,本节课,我带你了解了网络 I/O 模型和序列化方式的选择,它们是实现高并发 RPC 框架的要素,总结起来有三个要点:

  1. 选择高性能的 I/O 模型,这里我推荐使用同步多路 I/O 复用模型;
  2. 调试网络参数,这里面有一些经验值的推荐。比如将 tcp_nodelay 设置为 true,也有一些参数需要在运行中来调试,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)等等;
  3. 序列化协议依据具体业务来选择。如果对性能要求不高,可以选择 JSON,否则可以从 Thrift 和 Protobuf 中选择其一。

在学习本节课的过程中,我建议你阅读一下,成熟的 RPC 框架的源代码。比如,阿里开源的 Dubbo,微博的 Motan 等等,理解它们的实现原理和细节,这样你会更有信心维护好你的微服务系统;同时,你也可以从优秀的代码中,学习到代码设计的技巧,比如说 Dubbo 对于 RPC 的抽象,SPI 扩展点的设计,这样可以有助你提升代码能力。

当然了,本节课我不仅仅想让你了解 RPC 框架实现的一些原理,更想让你了解在做网络编程时,需要考虑哪些关键点,这样你在设计此类型的系统时,就会有一些考虑的方向和思路了。

4.23 - CH24-分布式-注册中心

上一节课,我带你了解了 RPC 框架实现中的一些关键的点,你通过 RPC 框架,能够解决服务之间,跨网络通信的问题,这就完成了微服务化改造的基础。

但是在服务拆分之后,你需要维护更多的细粒度的服务,而你需要面对的第一个问题就是,如何让 RPC 客户端知道服务端部署的地址,这就是我们今天要讲到的,服务注册与发现的问题。

你所知道的服务发现

服务注册和发现不是一个新的概念,你在之前的实际项目中也一定了解过,只是你可能没怎么注意罢了。比如说,你知道 Nginx 是一个反向代理组件,那么 Nginx 需要知道,应用服务器的地址是什么,这样才能够将流量透传到应用服务器上, 这就是服务发现的过程。

那么 Nginx 是怎么实现的呢? 它是把应用服务器的地址配置在了文件中。

这固然是一种解决的思路,实际上,我在早期的项目中也是这么做的。那时,项目刚刚做了服务化拆分,RPC 服务端的地址,就是配置在了客户端的代码中,不过,这样做之后出现了几个问题:

  • 首先在紧急扩容的时候,就需要修改客户端配置后,重启所有的客户端进程,操作时间比较长;
  • 其次,一旦某一个服务器出现故障时,也需要修改所有客户端配置后重启,无法快速修复,更无法做到自动恢复;
  • 最后,RPC 服务端上线无法做到提前摘除流量,这样在重启服务端的时候,客户端发往被重启服务端的请求还没有返回,会造成慢请求甚至请求失败。

因此,我们考虑使用 注册中心 来解决这些问题。

目前业界有很多可供你来选择的注册中心组件,比如说老派的 ZooKeeper,Kubernetes 使用的 ETCD,阿里的微服务注册中心 Nacos,Spring Cloud 的 Eureka 等等。

这些注册中心的基本功能有两点:

  • 其一是提供了服务地址的存储;
  • 其二是当存储内容发生变化时,可以将变更的内容推送给客户端。

第二个功能是我们使用注册中心的主要原因。因为无论是,当我们需要紧急扩容,还是在服务器发生故障时,需要快速摘除节点,都不用重启服务器就可以实现了。使用了注册中心组件之后,RPC 的通信过程就变成了下面这个样子:

NAME

从图中,你可以看到一个完整的,服务注册和发现的过程:

  • 客户端会与注册中心建立连接,并且告诉注册中心,它对哪一组服务感兴趣;
  • 服务端向注册中心注册服务后,注册中心会将最新的服务注册信息通知给客户端;
  • 客户端拿到服务端的地址之后就可以向服务端发起调用请求了。

从这个过程中可以看出,有了注册中心之后,服务节点的增加和减少对于客户端就是透明的。这样,除了可以实现不重启客户端,就能动态地变更服务节点以外,还可以 实现优雅关闭的功能。

优雅关闭是你在系统研发过程中,必须要考虑的问题。因为如果暴力地停止服务,那么已经发送给服务端的请求,来不及处理服务就被杀掉了,就会造成这部分请求失败,服务就会有波动。所以,服务在退出的时候,都需要先停掉流量,再停止服务,这样服务的关闭才会更平滑,比如说,消息队列处理器就是要将所有,已经从消息队列中读出的消息,处理完之后才能退出。

对于 RPC 服务来说, 我们可以先将 RPC 服务从注册中心的服务列表中删除掉,然后观察 RPC 服务端没有流量之后,再将服务端停掉。有了优雅关闭之后,RPC 服务端再重启的时候,就会减少对客户端的影响。

在这个过程中,服务的上线和下线是由服务端主动向注册中心注册、和取消注册来实现的,这在正常的流程中是没有问题的。 可是,如果某一个服务端意外故障, 比如说机器掉电,网络不通等情况,服务端就没有办法向注册中心通信,将自己从服务列表中删除,那么客户端也就不会得到通知,它就会继续向一个故障的服务端发起请求,也就会有错误发生了。那这种情况如何来避免呢?其实,这种情况是一个服务状态管理的问题。

服务状态管理如何来做

针对上面我提到的问题, 我们一般会有两种解决思路。

主动探测

第一种思路是主动探测, 方法是这样的:

你的 RPC 服务要打开一个端口,然后由注册中心每隔一段时间(比如 30 秒)探测这些端口是否可用,如果可用就认为服务仍然是正常的,否则就可以认为服务不可用,那么注册中心就可以把服务从列表里面删除了。

NAME

微博早期的注册中心就是采用这种方式,但是后面出现的两个问题,让我们不得不对它做改造。

第一个问题是: 所有的 RPC 服务端都需要,开放一个统一的端口给注册中心探测,那时候还没有容器化,一台物理机上会混合部署很多的服务,你需要开放的端口很可能已经被占用,这样会造成 RPC 服务启动失败。

还有一个问题是: 如果 RPC 服务端部署的实例比较多,那么每次探测的成本也会比较高,探测的时间也比较长,这样当一个服务不可用时,可能会有一段时间的延迟,才会被注册中心探测到。

因此,我们后面把它改造成了心跳模式。

心跳模式

这也是大部分注册中心提供的,检测连接上来的 RPC 服务端是否存活的方式,比如 Eureka、ZooKeeper, 在我来看,这种心跳机制可以这样实现:

注册中心为每一个连接上来的 RPC 服务节点,记录最近续约的时间 ,RPC 服务节点在启动注册到注册中心后,就按照一定的时间间隔(比如 30 秒),向注册中心发送心跳包。注册中心在接受到心跳包之后,会更新这个节点的最近续约时间。然后,注册中心会启动一个定时器,定期检测当前时间和节点,最近续约时间的差值,如果达到一个阈值(比如说 90 秒),那么认为这个服务节点不可用。

NAME

在实际的使用中, 心跳机制相比主动探测的机制,适用范围更广,如果你的服务也需要检测是否存活,那么也可以考虑使用心跳机制来检测。

接着说回来,有了心跳机制之后,注册中心就可以管理注册的服务节点的状态了,也让你的注册中心成为了整体服务最重要的组件,因为一旦它出现问题或者代码出现 Bug,那么很可能会导致整个集群的故障,给你举一个真实的案例。

在我之前的一个项目中, 工程是以「混合云」的方式部署的,也就是一部分节点部署在自建机房中,一部分节点部署在云服务器上,每一个机房都部署了自研的一套注册中心,每套注册中心中都保存了全部节点的数据。

这套自研的注册中心使用 Redis 作为最终的存储,而在自建机房和云服务器上的注册中心,共用同一套 Redis 存储资源。由于「混合云」还处在测试阶段,所以,所有的流量还都在自建机房,自建机房和云服务器之前的专线带宽还比较小,部署结构如下:

NAME

在测试的过程中,系统运行稳定,但是某一天早上五点,我突然发现,所有的服务节点都被摘除了,客户端因为拿不到服务端的节点地址列表全部调用失败,整体服务宕机。经过排查我发现,云服务器上部署的注册中心,竟然将所有的服务节点全部删除了!进一步排查之后, 原来是自研注册中心出现了 Bug。

在正常的情况下,无论是自建机房,还是云服务器上的服务节点,都会向各自机房的注册中心注册地址信息,并且发送心跳。而这些地址信息,以及服务的最近续约时间,都是存储在 Redis 主库中,各自机房的注册中心,会读各自机房的从库来获取最近续约时间,从而判断服务节点是否有效。

Redis 的主从同步数据是通过专线来传输的,出现故障之前,专线带宽被占满,导致主从同步延迟。这样一来,云上部署的 Redis 从库中存储的最近续约时间,就没有得到及时更新,随着主从同步延迟越发严重,最终,云上部署的注册中心发现了,当前时间与最近续约时间的差值,超过了摘除的阈值,所以将所有的节点摘除,从而导致了故障。

有了这次惨痛的教训, 我们给注册中心增加了保护的策略: 如果摘除的节点占到了服务集群节点数的 40%,就停止摘除服务节点,并且给服务的开发同学和,运维同学报警处理(这个阈值百分比可以调整,保证了一定的灵活性)。

据我所知, Eureka 也采用了类似的策略,来避免服务节点被过度摘除,导致服务集群不足以承担流量的问题。如果你使用的是 ZooKeeper 或者 ETCD 这种无保护策略的分布式一致性组件,那你可以考虑在客户端,实现保护策略的逻辑,比如说当摘除的节点超过一定比例时,你在 RPC 客户端就不再处理变更通知,你可以依据自己的实际情况来实现。

除此之外,在实际项目中,我们还发现注册中心另一个重要的问题就是「通知风暴」。你想一想,变更一个服务的一个节点,会产生多少条推送消息?假如你的服务有 100 个调用者,有 100 个节点,那么变更一个节点会推送 100 * 100 = 10000 个节点的数据。那么如果多个服务集群同时上线或者发生波动时,注册中心推送的消息就会更多,会严重占用机器的带宽资源,这就是我所说的「通知风暴」。 那么怎么解决这个问题呢? 你可以从以下几个方面来思考:

  • 首先,要控制一组注册中心管理的服务集群的规模,具体限制多少没有统一的标准,你需要结合你的业务以及注册中心的选型来考虑,主要考察的指标就是注册中心服务器的峰值带宽;
  • 其次,你也可以通过扩容注册中心节点的方式来解决;
  • 再次,你可以规范一下对于注册中心的使用方式,如果只是变更某一个节点,那么只需要通知这个节点的变更信息即可;
  • 最后,如果是自建的注册中心,你也可以在其中加入一些保护策略,比如说如果通知的消息量达到某一个阈值就停止变更通知。

其实,服务的注册和发现,归根结底是服务治理中的一环, **服务治理(service governance), ** 其实更直白的翻译应该是服务的管理,也就是解决多个服务节点,组成集群的时候,产生的一些复杂的问题。为了帮助你理解, 我来做个简单的比喻。

你可以把集群看作是一个微型的城市,把道路看做是组成集群的服务,把行走在道路上的车当做是流量,那么服务治理就是对于整个城市道路的管理。

如果你新建了一条街道(相当于启动了一个新的服务节点),那么就要通知所有的车辆(流量)有新的道路可以走了;你关闭了一条街道,你也要通知所有车辆不要从这条路走了, 这就是服务的注册和发现。

我们在道路上安装监控,监视每条道路的流量情况, 这就是服务的监控。

道路一旦出现拥堵或者道路需要维修,那么就需要暂时封闭这条道路,由城市来统一调度车辆,走不堵的道路, 这就是熔断以及引流。

道路之间纵横交错四通八达,一旦在某条道路上出现拥堵,但是又发现这条道路从头堵到尾,说明事故并不是发生在这条道路上,那么就需要从整体链路上来排查事故究竟处在哪个位置, 这就是分布式追踪。

不同道路上的车辆有多有少,那么就需要有一个警察来疏导,在某一个时间走哪一条路会比较快, 这就是负载均衡。

而这些问题,我会在后面的课程中针对性地讲解。

课程小结

本节课,我带你了解了在微服务架构中,注册中心是如何实现服务的注册和发现的,以及在实现中遇到的一些坑,除此之外,我还带你了解了服务治理的含义,以及后续我们会讲到的一些技术点。在这节课中,我想让你明确的重点如下:

  • 注册中心可以让我们动态地,变更 RPC 服务的节点信息,对于动态扩缩容,故障快速恢复,以及服务的优雅关闭都有重要的意义;
  • 心跳机制是一种常见的探测服务状态的方式,你在实际的项目中也可以考虑使用;
  • 我们需要对注册中心中管理的节点提供一些保护策略,避免节点被过度摘除导致的服务不可用。

你看,注册中心虽然是一种简单易懂的分布式组件,但是它在整体架构中的位置至关重要,不容忽视。同时,在它的设计方案中,也蕴含了一些系统设计的技巧,比如上,面提到的服务状态检测的方式,还有上面提到的优雅关闭的方式,了解注册中心的原理,会给你之后的研发工作提供一些思路。

4.24 - CH25-分布式-追踪

经过前面几节课的学习,你的垂直电商系统在引入 RPC 框架,和注册中心之后已经完成基本的服务化拆分了,系统架构也有了改变:

NAME

现在,你的系统运行平稳,老板很高兴,你也安心了很多。而且你认为,在经过了服务化拆分之后,服务的可扩展性增强了很多,可以通过横向扩展服务节点的方式,进行平滑地扩容了,对于应对峰值流量也更有信心了。

但是这时出现了问题: 你通过监控发现,系统的核心下单接口在晚高峰的时候,会有少量的慢请求,用户也投诉在 APP 上下单时,等待的时间比较长。而下单的过程可能会调用多个 RPC 服务,或者使用多个资源,一时之间,你很难快速判断,究竟是哪个服务或者资源出了问题,从而导致整体流程变慢, 于是,你和你的团队开始想办法如何排查这个问题。

一体化架构中的慢请求排查如何做

因为在分布式环境下,请求要在多个服务之间调用,所以对于慢请求问题的排查会更困难, 我们不妨从简单的入手, 先看看在一体化架构中,是如何排查这个慢请求的问题的。

最简单的思路是:打印下单操作的每一个步骤的耗时情况,然后通过比较这些耗时的数据,找到延迟最高的一步,然后再来看看这个步骤要如何的优化。如果有必要的话,你还需要针对步骤中的子步骤,再增加日志来继续排查, 简单的代码就像下面这样:

long start = System.currentTimeMillis();
processA();
Logs.info("process A cost " + (System.currentTimeMillis() - start));// 打印 A 步骤的耗时
start = System.currentTimeMillis();
processB();
Logs.info("process B cost " + (System.currentTimeMillis() - start));// 打印 B 步骤的耗时
start = System.currentTimeMillis();
processC();
Logs.info("process C cost " + (System.currentTimeMillis() - start));// 打印 C 步骤的耗时

这是最简单的实现方式,打印出日志后,我们可以登录到机器上,搜索关键词来查看每个步骤的耗时情况。

虽然这个方式比较简单,但你可能很快就会遇到问题: 由于同时会有多个下单请求并行处理,所以,这些下单请求的每个步骤的耗时日志,是相互穿插打印的。你无法知道这些日志,哪些是来自于同一个请求,也就不能很直观地看到,某一次请求耗时最多的步骤是哪一步了。那么,你要如何把单次请求,每个步骤的耗时情况串起来呢?

一个简单的思路是: 给同一个请求的每一行日志,增加一个相同的标记。这样,只要拿到这个标记就可以查询到这个请求链路上,所有步骤的耗时了,我们把这个标记叫做 requestId,我们可以在程序的入口处生成一个 requestId,然后把它放在线程的上下文中,这样就可以在需要时,随时从线程上下文中获取到 requestId 了。简单的代码实现就像下面这样:

String requestId = UUID.randomUUID().toString();
ThreadLocal<String> tl = new ThreadLocal<String>(){
    @Override
    protected String initialValue() {
        return requestId;
    }
}; //requestId 存储在线程上下文中
long start = System.currentTimeMillis();
processA();
Logs.info("rid : " + tl.get() + ", process A cost " + (System.currentTimeMillis() - start)); // 日志中增加 requestId
start = System.currentTimeMillis();
processB();
Logs.info("rid : " + tl.get() + ", process B cost " + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
processC();
Logs.info("rid : " + tl.get() + ", process C cost " + (System.currentTimeMillis() - start));

有了 requestId,你就可以清晰地了解一个调用链路上的耗时分布情况了。

于是,你给你的代码增加了大量的日志,来排查下单操作缓慢的问题。 很快, 你发现是某一个数据库查询慢了才导致了下单缓慢,然后你优化了数据库索引,问题最终得到了解决。

正当你要松一口气的时候,问题接踵而至: 又有用户反馈某些商品业务打开缓慢;商城首页打开缓慢。你开始焦头烂额地给代码中增加耗时日志,而这时你意识到,每次排查一个接口就需要增加日志、重启服务, 这并不是一个好的办法,于是你开始思考解决的方案。

其实,从我的经验出发来说, 一个接口响应时间慢,一般是出在跨网络的调用上,比如说请求数据库、缓存或者依赖的第三方服务。所以,我们只需要针对这些调用的客户端类,做切面编程,通过插入一些代码打印它们的耗时就好了。

说到切面编程(AOP)你应该并不陌生,它是面向对象编程的一种延伸,可以在不修改源代码的前提下,给应用程序添加功能,比如说鉴权,打印日志等等。如果你对切面编程的概念理解的还不透彻,那我给你做个比喻, 帮你理解一下。

这就像开发人员在向代码仓库提交代码后,他需要对代码编译、构建、执行单元测试用例,以保证提交的代码是没有问题的。但是,如果每个人提交了代码都做这么多事儿,无疑会对开发同学造成比较大的负担,那么你可以配置一个持续集成的流程,在提交代码之后,自动帮你完成这些操作,这个持续集成的流程就可以认为是一个切面。

一般来说,切面编程的实现分为两类:

  • 一类是静态代理,典型的代表是 AspectJ,它的特点是在编译期做切面代码注入;
  • 另一类是动态代理,典型的代表是 Spring AOP,它的特点是在运行期做切面代码注入。

这两者有什么差别呢? 以 Java 为例,源代码 Java 文件先被 Java 编译器,编译成 Class 文件,然后 Java 虚拟机将 Class 装载进来之后,进行必要的验证和初始化后就可以运行了。

静态代理是在编译期插入代码,增加了编译的时间,给你的直观感觉就是启动的时间变长了,但是一旦在编译期插入代码完毕之后,在运行期就基本对于性能没有影响。

而动态代理不会去修改生成的 Class 文件,而是会在运行期生成一个代理对象,这个代理对象对源对象做了字节码增强,来完成切面所要执行的操作。由于在运行期需要生成代理对象,所以动态代理的性能要比静态代理要差。

我们做切面的原因,是想生成一些调试的日志,所以我们期望尽量减少对于原先接口性能的影响。 因此,我推荐采用静态代理的方式,实现切面编程。

如果你的系统中需要增加切面,来做一些校验、限流或者日志打印的工作, 我也建议你考虑使用静态代理的方式, 使用 AspectJ 做切面的简单代码实现就像下面这样:

@Aspect
public class Tracer {
    @Around(value = "execution(public methodsig)", argNames = "pjp") //execution 内替换要做切面的方法签名
    public Object trace(ProceedingJoinPoint pjp) throws Throwable {
        TraceContext traceCtx = TraceContext.get(); // 获取追踪上下文,上下文的初始化可以在程序入口处
        String requestId = reqCtx.getRequestId(); // 获取 requestId
        String sig = pjp.getSignature().toShortString(); // 获取方法签名
        boolean isSuccessful = false;
        String errorMsg = "";
        Object result = null;
        long start = System.currentTimeMillis();
        try {
            result = pjp.proceed();
            isSuccessful = true;
            return result;
        } catch (Throwable t) {
            isSuccessful = false;
            errorMsg = t.getMessage();
            return result;
        } finally {
            long elapseTime = System.currentTimeMillis() - start;
            Logs.info("rid : " + requestId + ", start time: " + start + ", elapseTime: " + elapseTime + ", sig: " + sig + ", isSuccessful: " + isSuccessful + ", errorMsg: " + errorMsg  );
        }
    }
}

这样,你就在你的系统的每个接口中,打印出了所有访问数据库、缓存、外部接口的耗时情况,一次请求可能要打印十几条日志,如果你的电商系统的 QPS 是 10000 的话,就是每秒钟会产生十几万条日志,对于磁盘 I/O 的负载是巨大的, 那么这时,你就要考虑如何减少日志的数量。

你可以考虑对请求做采样, 采样的方式也简单,比如你想采样 10% 的日志,那么你可以只打印 requestId%10==0 的请求。

有了这些日志之后,当给你一个 requestId 的时候,你发现自己并不能确定这个请求到了哪一台服务器上,所以你不得不登陆所有的服务器,去搜索这个 requestId 才能定位请求。这样无疑会增加问题排查的时间。

你可以考虑的解决思路是: 把日志不打印到本地文件中,而是发送到消息队列里,再由消息处理程序写入到集中存储中,比如 Elasticsearch。这样,你在排查问题的时候,只需要拿着 requestId 到 Elasticsearch 中查找相关的记录就好了。在加入消息队列和 Elasticsearch 之后,我们这个排查程序的架构图也会有所改变:

NAME

我来总结一下,为了排查单次请求响应时间长的原因,我们主要做了哪些事情:

  1. 在记录打点日志时,我们使用 requestId 将日志串起来,这样方便比较一次请求中的多个步骤的耗时情况;
  2. 我们使用静态代理的方式做切面编程,避免在业务代码中,加入大量打印耗时的日志的代码,减少了对于代码的侵入性,同时编译期的代码注入可以减少;
  3. 我们增加了日志采样率,避免全量日志的打印;
  4. 最后为了避免在排查问题时,需要到多台服务器上搜索日志,我们使用消息队列,将日志集中起来放在了 Elasticsearch 中。

如何来做分布式 Trace

你可能会问:题目既然是「分布式 Trace:横跨几十个分布式组件的慢请求要如何排查?」,那么我为什么要花费大量的篇幅,来说明在一体化架构中如何排查问题呢? 这是因为在分布式环境下, 你基本上也是依据上面,我提到的这几点来构建分布式追踪的中间件的。

在一体化架构中,单次请求的所有的耗时日志,都被记录在一台服务器上,而在微服务的场景下,单次请求可能跨越多个 RPC 服务,这就造成了,单次的请求的日志会分布在多个服务器上。

当然,你也可以通过 requestId 将多个服务器上的日志串起来,但是仅仅依靠 requestId 很难表达清楚服务之间的调用关系,所以从日志中,就无法了解服务之间是谁在调用谁。因此,我们采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系(这里 traceId 就是 requestId),也就是使用 traceId 串起单次请求,用 spanId 记录每一次 RPC 调用。 说起来可能比较抽象,我给你举一个具体的例子。

比如,你的请求从用户端过来,先到达 A 服务,A 服务会分别调用 B 和 C 服务,B 服务又会调用 D 和 E 服务。

NAME

我来给你讲讲图中的内容:

  • 用户到 A 服务之后会初始化一个 traceId 为 100,spanId 为 1;
  • A 服务调用 B 服务时,traceId 不变,而 spanId 用 1.1 标识,代表上一级的 spanId 是 1,这一级的调用次序是 1;
  • A 调用 C 服务时,traceId 依然不变,spanId 则变为了 1.2,代表上一级的 spanId 还是 1,而调用次序则变成了 2,以此类推。

通过这种方式,我们可以在日志中,清晰地看出服务的调用关系是如何的,方便在后续计算中调整日志顺序,打印出完整的调用链路。

那么 spanId 是何时生成的,又是如何传递的呢? 这部分内容可以算作一个延伸点,能够帮你了解分布式 trace 中间件的实现原理。

首先,A 服务在发起 RPC 请求服务 B 前,先从线程上下文中获取当前的 traceId 和 spanId,然后,依据上面的逻辑生成本次 RPC 调用的 spanId,再将 spanId 和 traceId 序列化后,装配到请求体中,发送给服务方 B。

服务方 B 获取请求后,从请求体中反序列化出 spanId 和 traceId,同时设置到线程上下文中,以便给下次 RPC 调用使用。在服务 B 调用完成返回响应前,计算出服务 B 的执行时间发送给消息队列。

当然,在服务 B 中,你依然可以使用切面编程的方式,得到所有调用的数据库、缓存、HTTP 服务的响应时间,只是在发送给消息队列的时候,要加上当前线程上下文中的 spanId 和 traceId。

这样,无论是数据库等资源的响应时间,还是 RPC 服务的响应时间就都汇总到了消息队列中,在经过一些处理之后,最终被写入到 Elasticsearch 中以便给开发和运维同学查询使用。

而在这里,你大概率会遇到的问题还是性能的问题,也就是因为引入了分布式追踪中间件,导致对于磁盘 I/O 和网络 I/O 的影响, 而我给你的「避坑」指南就是: 如果你是自研的分布式 trace 中间件,那么一定要提供一个开关,方便在线上随时将日志打印关闭;如果使用开源的组件,可以开始设置一个较低的日志采样率,观察系统性能情况再调整到一个合适的数值。

课程小结

本节课我带你了解了在一体化架构和服务化架构中,你要如何排查单次慢请求中,究竟哪一个步骤是瓶颈,这里你需要了解的主要有以下几个重点:

  • 服务的追踪的需求主要有两点:
    • 一点对代码要无侵入,你可以使用切面编程的方式来解决;
    • 另一点是性能上要低损耗,我建议你采用静态代理和日志采样的方式,来尽量减少追踪日志对于系统性能的影响;
  • 无论是单体系统还是服务化架构,无论是服务追踪还是业务问题排查,你都需要在日志中增加 requestId,这样可以将你的日志串起来,给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成,在请求业务接口的时候传递给服务端,那么就可以把客户端的日志体系也整合进来,对于问题的排查帮助更大。

其实,分布式追踪系统不是一项新的技术,而是若干项已有技术的整合,在实现上并不复杂,却能够帮助你实现跨进程调用链展示、服务依赖分析,在性能优化和问题排查方面提供数据上的支持。所以,在微服务化过程中,它是一个必选项,无论是采用 Zipkin,Jaeger 这样的开源解决方案,还是团队内自研,你都应该在微服务化完成之前,尽快让它发挥应有的价值。

4.25 - CH26-分布式-负载均衡

在基础篇中,我提到了高并发系统设计的三个通用方法:缓存、异步和横向扩展,到目前为止,你接触到了缓存的使用姿势,也了解了,如何使用消息队列异步处理业务逻辑,那么本节课,我将带你了解一下,如何提升系统的横向扩展能力。

在之前的课程中,我也提到过提升系统横向扩展能力的一些案例。比如,08 讲提到,可以通过部署多个从库的方式,来提升数据库的扩展能力,从而提升数据库的查询性能,那么就需要借助组件,将查询数据库的请求,按照一些既定的策略分配到多个从库上,这是负载均衡服务器所起的作用,而我们一般使用 DNS 服务器 来承担这个角色。

不过在实际的工作中,你经常使用的负载均衡的组件应该算是 Nginx,它的作用是承接前端的 HTTP 请求,然后将它们按照多种策略,分发给后端的多个业务服务器上。这样,我们可以随时通过扩容业务服务器的方式,来抵挡突发的流量高峰。与 DNS 不同的是,Nginx 可以在域名和请求 URL 地址的层面做更细致的流量分配,也提供更复杂的负载均衡策略

你可能会想到,在微服务架构中,我们也会启动多个服务节点,来承接从用户端到应用服务器的请求,自然会需要一个负载均衡服务器,作为流量的入口,实现流量的分发。那么在微服务架构中,如何使用负载均衡服务器呢?

在回答这些问题之前,我先带你了解一下,常见的负载均衡服务器都有哪几类,因为这样,你就可以依据不同类型负载均衡服务器的特点做选择了。

负载均衡服务器的种类

负载均衡的含义是: 将负载(访问的请求)「均衡」地分配到多个处理节点上。这样可以减少单个处理节点的请求量,提升整体系统的性能。

同时,负载均衡服务器作为流量入口,可以对请求方屏蔽服务节点的部署细节,实现对于业务方无感知的扩容。它就像交通警察,不断地疏散交通,将汽车引入合适的道路上。

而在我看来, 负载均衡服务大体上可以分为两大类:一类是 代理类的负载均衡服务 ;另一类是 客户端负载均衡服务

代理类的负载均衡服务

代理类的负载均衡服务,以单独的服务方式部署,所有的请求都要先经过负载均衡服务,在负载均衡服务中,选出一个合适的服务节点后,再由负载均衡服务,调用这个服务节点来实现流量的分发。

NAME

由于这类服务需要承担全量的请求,所以对于性能的要求极高。代理类的负载均衡服务有很多开源实现,比较著名的有 LVS,Nginx 等等。LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载;而 Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载(你可以回顾一下02 讲的内容)。

在项目的架构中,我们一般会同时部署 LVS 和 Nginx 来做 HTTP 应用服务的负载均衡。也就是说,在入口处部署 LVS,将流量分发到多个 Nginx 服务器上,再由 Nginx 服务器分发到应用服务器上, 为什么这么做呢?

主要和 LVS 和 Nginx 的特点有关,LVS 是在网络栈的四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器,所以相比 Nginx,性能会更高,也能够承担更高的并发。

可 LVS 缺陷是工作在四层,而请求的 URL 是七层的概念,不能针对 URL 做更细致地请求分发,而且 LVS 也没有提供探测后端服务是否存活的机制;而 Nginx 虽然比 LVS 的性能差很多,但也可以承担每秒几万次的请求,并且它在配置上更加灵活,还可以感知后端服务是否出现问题。

因此,LVS 适合在入口处,承担大流量的请求分发,而 Nginx 要部署在业务服务器之前做更细维度的请求分发。 我给你的建议是, 如果你的 QPS 在十万以内,那么可以考虑不引入 LVS 而直接使用 Nginx 作为唯一的负载均衡服务器,这样少维护一个组件,也会减少系统的维护成本。

不过这两个负载均衡服务适用于普通的 Web 服务,对于微服务架构来说,它们是不合适的。因为微服务架构中的服务节点存储在注册中心里,使用 LVS 就很难和注册中心交互,获取全量的服务节点列表。另外,一般微服务架构中,使用的是 RPC 协议而不是 HTTP 协议,所以 Nginx 也不能满足要求。

客户端负载均衡服务

所以,我们会使用另一类的负载均衡服务,客户端负载均衡服务,也就是把负载均衡的服务内嵌在 RPC 客户端中。

它一般和客户端应用,部署在一个进程中,提供多种选择节点的策略,最终为客户端应用提供一个最佳的,可用的服务端节点。这类服务一般会结合注册中心来使用,注册中心提供服务节点的完整列表,客户端拿到列表之后使用负载均衡服务的策略选取一个合适的节点,然后将请求发到这个节点上。

NAME

了解负载均衡服务的分类,是你学习负载均衡服务的第一步,接下来,你需要掌握负载均衡策略,这样一来,你在实际工作中,配置负载均衡服务的时候,可以对原理有更深刻的了解。

常见的负载均衡策略有哪些

负载均衡策略从大体上来看可以分为两类:

  • 一类是静态策略,也就是说负载均衡服务器在选择服务节点时,不会参考后端服务的实际运行的状态。
  • 一类是动态策略,也就是说负载均衡服务器会依据后端服务的一些负载特性,来决定要选择哪一个服务节点。

静态策略

常见的静态策略有几种,其中使用最广泛的是 轮询的策略(RoundRobin,RR), 这种策略会记录上次请求后端服务的地址或者序号,然后在请求时,按照服务列表的顺序,请求下一个后端服务节点。伪代码如下:

AtomicInteger lastCounter = getLastCounter();// 获取上次请求的服务节点的序号 
List<String> serverList = getServerList(); // 获取服务列表
int currentIndex = lastCounter.addAndGet(); // 增加序列号
if(currentIndex >= serverList.size()) {
  currentIndex = 0;
}
setLastCounter(currentIndex);
return serverList.get(currentIndex);

它其实是一种通用的策略,基本上,大部分的负载均衡服务器都支持。轮询的策略可以做到将请求尽量平均地分配到所有服务节点上,但是,它没有考虑服务节点的具体配置情况。比如,你有三个服务节点,其中一个服务节点的配置是 8 核 8G,另外两个节点的配置是 4 核 4G,那么如果使用轮询的方式来平均分配请求的话,8 核 8G 的节点分到的请求数量和 4 核 4G 的一样多,就不能发挥性能上的优势了

所以,我们考虑给节点加上权重值,比如给 8 核 8G 的机器配置权重为 2,那么就会给它分配双倍的流量, 这种策略就是带有权重的轮询策略。

除了这两种策略之外,目前开源的负载均衡服务还提供了很多静态策略:

  • Nginx 提供了 ip_hash 和 url_hash 算法;
  • LVS 提供了按照请求的源地址,和目的地址做 hash 的策略;
  • Dubbo 也提供了随机选取策略,以及一致性 hash 的策略。

但是在我看来, 轮询和带有权重的轮询策略,能够将请求尽量平均地分配到后端服务节点上,也就能够做到对于负载的均衡分配,在没有更好的动态策略之前,应该优先使用这两种策略,比如 Nginx 就会优先使用轮询的策略。

动态策略

而目前开源的负载均衡服务中,也会提供一些动态策略,我强调一下它们的原理。

在负载均衡服务器上会收集对后端服务的调用信息,比如从负载均衡端到后端服务的活跃连接数,或者是调用的响应时间,然后从中选择连接数最少的服务,或者响应时间最短的后端服务。 我举几个具体的例子:

  • Dubbo 提供的 LeastAcive 策略,就是优先选择活跃连接数最少的服务;
  • Spring Cloud 全家桶中的 Ribbon 提供了 WeightedResponseTimeRule 是使用响应时间,给每个服务节点计算一个权重,然后依据这个权重,来给调用方分配服务节点。

这些策略的思考点 是从调用方的角度出发,选择负载最小、资源最空闲的服务来调用,以期望能得到更高的服务调用性能,也就能最大化地使用服务器的空闲资源,请求也会响应地更迅速, 所以,我建议你, 在实际开发中,优先考虑使用动态的策略。

到目前为止,你已经可以根据上面的分析,选择适合自己的负载均衡策略,并选择一个最优的服务节点, 那么问题来了: 你怎么保证选择出来的这个节点,一定是一个可以正常服务的节点呢?如果你采用的是轮询的策略,选择出来的,是一个故障节点又要怎么办呢?所以,为了降低请求被分配到一个故障节点的几率,有些负载均衡服务器,还提供了对服务节点的故障检测功能。

如何检测节点是否故障

24 讲中,我带你了解到,在微服务化架构中,服务节点会定期地向注册中心发送心跳包,这样注册中心就能够知晓服务节点是否故障,也就可以确认传递给负载均衡服务的节点,一定是可用的。

但对于 Nginx 来说, 我们要如何保证配置的服务节点是可用的呢?

这就要感谢淘宝开源的 Nginx 模块 nginx_upstream_check_module 了,这个模块可以让 Nginx 定期地探测后端服务的一个指定的接口,然后根据返回的状态码,来判断服务是否还存活。当探测不存活的次数达到一定阈值时,就自动将这个后端服务从负载均衡服务器中摘除。 它的配置样例如下:

upstream server {
        server 192.168.1.1:8080;
        server 192.168.1.2:8080;
        // 检测间隔为 3 秒,检测超时时间是 1 秒,使用 http 协议。如果连续失败次数达到 5 次就认为服务不可用;如果连续连续成功次数达到 2 次,则认为服务可用。后端服务刚启动时状态是不可用的
        check interval=3000 rise=2 fall=5 timeout=1000 type=http default_down=true;
        check_http_send "GET /health_check HTTP/1.0\r\n\r\n"; // 检测 URL
        check_http_expect_alive http_2xx; // 检测返回状态码为 200 时认为检测成功
}

Nginx 按照上面的方式配置之后,你的业务服务器也要实现一个 /health_check 的接口,在这个接口中返回的 HTTP 状态码,这个返回的状态码可以存储在配置中心中,这样在变更状态码时,就不需要重启服务了(配置中心在第 33 节课中会讲到)。

节点检测的功能,还能够帮助我们实现 Web 服务的优雅关闭。在 24 讲中介绍注册中心时,我曾经提到,服务的优雅关闭需要先切除流量再关闭服务,使用了注册中心之后,就可以先从注册中心中摘除节点,再重启服务,以便达到优雅关闭的目的。那么 Web 服务要如何实现优雅关闭呢?接下来,我来给你了解一下,有了节点检测功能之后,服务是如何启动和关闭的。

在服务刚刚启动时, 可以初始化默认的 HTTP 状态码是 500,这样 Nginx 就不会很快将这个服务节点标记为可用,也就可以等待服务中,依赖的资源初始化完成,避免服务初始启动时的波动。

在完全初始化之后, 再将 HTTP 状态码变更为 200,Nginx 经过两次探测后,就会标记服务为可用。在服务关闭时,也应该先将 HTTP 状态码变更为 500,等待 Nginx 探测将服务标记为不可用后,前端的流量也就不会继续发往这个服务节点。在等待服务正在处理的请求全部处理完毕之后,再对服务做重启,可以避免直接重启导致正在处理的请求失败的问题。 这是启动和关闭线上 Web 服务时的标准姿势,你可以在项目中参考使用。

课程小结

本节课,我带你了解了与负载均衡服务相关的一些知识点,以及在实际工作中的运用技巧。我想强调几个重点:

  • 网站负载均衡服务的部署,是以 LVS 承接入口流量,在应用服务器之前,部署 Nginx 做细化的流量分发,和故障节点检测。当然,如果你的网站的并发不高,也可以考虑不引入 LVS。
  • 负载均衡的策略可以优先选择动态策略,保证请求发送到性能最优的节点上;如果没有合适的动态策略,那么可以选择轮询的策略,让请求平均分配到所有的服务节点上。
  • Nginx 可以引入 nginx_upstream_check_module,对后端服务做定期的存活检测,后端的服务节点在重启时,也要秉承着「先切流量后重启」的原则,尽量减少节点重启对于整体系统的影响。

你可能会认为,像 Nginx、LVS 应该是运维所关心的组件,作为开发人员不用操心维护。 不过通过今天的学习你应该可以看到: 负载均衡服务是提升系统扩展性,和性能的重要组件,在高并发系统设计中,它发挥的作用是无法替代的。理解它的原理,掌握使用它的正确姿势,应该是每一个后端开发同学的必修课。

4.26 - CH27-分布式-接口网关

到目前为止,你的垂直电商系统在经过微服务化拆分之后,已经运行了一段时间了,系统的扩展性得到了很大的提升,也能够比较平稳地度过高峰期的流量了。

不过最近你发现,随着自己的电商网站知名度越来越高,系统迎来了一些「不速之客」,在凌晨的时候,系统中的搜索商品和用户接口的调用量,会有激剧的上升,持续一段时间之后又回归正常。

这些搜索请求有一个共同特征是,来自固定的几台设备。 当你在搜索服务上加一个针对设备 ID 的限流功能之后,凌晨的高峰搜索请求不见了。但是不久之后,用户服务也出现了大量爬取用户信息的请求,商品接口出现了大量爬取商品信息的请求。你不得不在这两个服务上也增加一样的限流策略。

但是这样会有一个问题: 不同的三个服务上使用同一种策略,在代码上会有冗余,无法做到重用,如果其他服务上也出现类似的问题,还要通过拷贝代码来实现,肯定是不行的。

不过作为 Java 程序员, 你很容易想到: 将限流的功能独立成一个单独的 jar 包,给这三个服务来引用。不过你忽略了一种情况,那就是你的电商团队使用的除了 Java,还有 PHP 和 Golang 等多种语言。

用多种语言开发的服务是没有办法使用 jar 包,来实现限流功能的, 这时你需要引入 API 网关。

API 网关起到的作用

API 网关(API Gateway)不是一个开源组件,而是一种架构模式,它是将一些服务共有的功能整合在一起,独立部署为单独的一层,用来解决一些服务治理的问题。你可以把它看作系统的边界,它可以对出入系统的流量做统一的管控。

在我看来,API 网关可以分为两类: 一类叫做入口网关,一类叫做出口网关。

入口网关是我们经常使用的网关种类,它部署在负载均衡服务器和应用服务器之间, 主要有几方面的作用。

  1. 它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。 在你的系统中,你部署的微服务对外暴露的协议可能不同: 有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址,以及协议的细节,给客户端的调用带来很大的便捷。
  2. 另一方面,在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级,流量控制和分流等等(关于服务降级和流量控制的细节,我会在后面的课程中具体讲解,在这里,你只要知道它们可以在 API 网关中实现就可以了)。
  3. 再有,客户端的认证和授权的实现,也可以放在 API 网关中。你要知道,不同类型的客户端使用的认证方式是不同的。 在我之前项目中, 手机 APP 使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上,可以得到统一处理,应用服务不需要了解认证的细节。
  4. 另外,API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
  5. 最后,在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,我在 25 讲中讲述分布式追踪系统时,提到的标记一次请求的 requestId,也可以在网关中来生成。
NAME

出口网关就没有这么丰富的功能和作用了。 我们在系统开发中,会依赖很多外部的第三方系统,比如典型的例子:第三方账户登录、使用第三方工具支付等等。我们可以在应用服务器和第三方系统之间,部署出口网关,在出口网关中,对调用外部的 API 做统一的认证、授权,审计以及访问控制。

NAME

我花一定的篇幅去讲 API 网关起到的作用,主要是想让你了解,API 网关可以解决什么样的实际问题,这样,当你在面对这些问题时,你就会有解决的思路,不会手足无措了。

API 网关要如何实现

了解 API 网关的作用之后,所以接下来,我们来看看 API 网关在实现中需要关注哪些点,以及常见的开源 API 网关有哪些,这样,你在实际工作中,无论是考虑自研 API 网关还是使用开源的实现都会比较自如了。

在实现一个 API 网关时,你首先要考虑的是它的性能 。这很好理解,API 入口网关承担从客户端的所有流量。假如业务服务处理时间是 10ms,而 API 网关的耗时在 1ms,那么相当于每个接口的响应时间都要增加 10%,这对于性能的影响无疑是巨大的。而提升 API 网关性能的关键还是在 I/O 模型上(我在 23 讲中详细讲到过),这里只是举一个例子来说明 I/O 模型对于性能的影响。

Netfix 开源的 API 网关 Zuul,在 1.0 版本的时候使用的是同步阻塞 I/O 模型,整体系统其实就是一个 servlet,在接收到用户的请求,然后执行在网关中配置的认证、协议转换等逻辑之后,调用后端的服务获取数据返回给用户。

而在 Zuul2.0 中,Netfix 团队将 servlet 改造成了一个 netty server(netty 服务),采用 I/O 多路复用的模型处理接入的 I/O 请求,并且将之前同步阻塞调用后端服务的方式,改造成使用 netty client(netty 客户端)非阻塞调用的方式。改造之后,Netfix 团队经过测试发现性能提升了 20% 左右。

除此之外,API 网关中执行的动作有些是可以预先定义好的,比如黑白名单的设置,接口动态路由;有些则是需要业务方依据自身业务来定义。 所以,API 网关的设计要注意扩展性, 也就是你可以随时在网关的执行链路上,增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。

所以一般来说,我们可以把每一个操作定义为一个 filter(过滤器),然后使用「责任链模式」将这些 filter 串起来。责任链可以动态地组织这些 filter,解耦 filter 之间的关系,无论是增加还是减少 filter,都不会对其他的 filter 有任何的影响。

Zuul 就是采用责任链模式, Zuul1 中将 filter 定义为三类:pre routing filter(路由前过滤器)、routing filter(路由过滤器)和 after routing filter(路由后过滤器)。每一个 filter 定义了执行的顺序,在 filter 注册时,会按照顺序插入到 filter chain(过滤器链)中。这样 Zuul 在接收到请求时,就会按照顺序依次执行插入到 filter chain 中的 filter 了。

NAME

另外还需要注意的一点是, 为了提升网关对于请求的并行处理能力,我们一般会使用线程池来并行的执行请求。 不过,这就带来一个问题: 如果商品服务出现问题,造成响应缓慢,那么调用商品服务的线程就会被阻塞无法释放,久而久之,线程池中的线程就会被商品服务所占据,那么其他服务也会受到级联的影响。因此,我们需要针对不同的服务做线程隔离,或者保护。 在我看来有两种思路:

  • 如果你后端的服务拆分得不多,可以针对不同的服务,采用不同的线程池,这样商品服务的故障就不会影响到支付服务和用户服务了;
  • 在线程池内部可以针对不同的服务,甚至不同的接口做线程的保护。比如说,线程池的最大线程数是 1000,那么可以给每个服务设置一个最多可以使用的配额。

一般来说,服务的执行时间应该在毫秒级别,线程被使用后会很快被释放,回到线程池给后续请求使用,同时处于执行中的线程数量不会很多,对服务或者接口设置线程的配额,不会影响到正常的执行。可是一旦发生故障,某个接口或者服务的响应时间变长,造成线程数暴涨,但是因为有配额的限制,也就不会影响到其他的接口或者服务了。

你在实际应用中也可以将这两种方式结合, 比如说针对不同的服务使用不同的线程池,在线程池内部针对不同的接口设置配额。

以上就是实现 API 网关的一些关键的点,你如果要自研 API 网关服务的话可以参考借鉴。另外 API 网关也有很多开源的实现,目前使用比较广泛的有以下几个:

  • Kong 是在 Nginx 中运行的 Lua 程序。得益于 Nginx 的性能优势,Kong 相比于其它的开源 API 网关来说,性能方面是最好的。由于大中型公司对于 Nginx 运维能力都比较强,所以选择 Kong 作为 API 网关,无论是在性能还是在运维的把控力上,都是比较好的选择;
  • Zuul 是 Spring Cloud 全家桶中的成员,如果你已经使用了 Spring Cloud 中的其他组件,那么也可以考虑使用 Zuul 和它们无缝集成。不过,Zuul1 因为采用同步阻塞模型,所以在性能上并不是很高效,而 Zuul2 推出时间不长,难免会有坑。但是 Zuul 的代码简单易懂,可以很好的把控,并且你的系统的量级很可能达不到 Netfix 这样的级别,所以对于 Java 技术栈的团队,使用 Zuul 也是一个不错的选择;
  • Tyk 是一种 Go 语言实现的轻量级 API 网关,有着丰富的插件资源,对于 Go 语言栈的团队来说,也是一种不错的选择。

那么你要考虑的是, 这些开源项目适不适合作为 AIP 网关供自己使用。而接下来,我以电商系统为例,带你将 API 网关引入我们的系统之中。

如何在你的系统中引入 API 网关呢?

目前为止,我们的电商系统已经经过了服务化改造,在服务层和客户端之间有一层薄薄的 Web 层, 这个 Web 层做的事情主要有两方面:

一方面是对服务层接口数据的聚合。比如,商品详情页的接口,可能会聚合服务层中,获取商品信息、用户信息、店铺信息以及用户评论等多个服务接口的数据;

另一方面 Web 层还需要将 HTTP 请求转换为 RPC 请求,并且对前端的流量做一些限制,对于某些请求添加设备 ID 的黑名单等等。

因此,我们在做改造的时候,可以先将 API 网关从 Web 层中独立出来,将协议转换、限流、黑白名单等事情,挪到 API 网关中来处理,形成独立的入口网关层;

而针对服务接口数据聚合的操作, 一般有两种解决思路:

  1. 再独立出一组网关专门做服务聚合、超时控制方面的事情,我们一般把前一种网关叫 做流量网关,后一种可以叫做业务网关;
  2. 抽取独立的服务层,专门做接口聚合的操作。这样服务层就大概分为 原子服务层聚合服务层

我认为,接口数据聚合是业务操作,与其放在通用的网关层来实现,不如放在更贴近业务的服务层来实现, 所以,我更倾向于第二种方案。

NAME

同时,我们可以在系统和第三方支付服务,以及登陆服务之间部署出口网关服务。原先,你会在拆分出来的支付服务中,完成对于第三方支付接口所需要数据的加密、签名等操作,再调用第三方支付接口,完成支付请求。现在,你把对数据的加密、签名的操作放在出口网关中,这样一来,支付服务只需要调用出口网关的统一支付接口就可以了。

在引入了 API 网关之后,我们的系统架构就变成了下面这样:

NAME

课程小结

本节课我带你了解了 API 网关在系统中的作用,在实现中的一些关键的点,以及如何将 API 网关引入你的系统, 我想强调的重点如下:

  1. API 网关分为入口网关和出口网关两类,入口网关作用很多,可以隔离客户端和微服务,从中提供协议转换、安全策略、认证、限流、熔断等功能。出口网关主要是为调用第三方服务提供统一的出口,在其中可以对调用外部的 API 做统一的认证、授权,审计以及访问控制;
  2. API 网关的实现重点在于性能和扩展性,你可以使用多路 I/O 复用模型和线程池并发处理,来提升网关性能,使用责任链模式来提升网关的扩展性;
  3. API 网关中的线程池,可以针对不同的接口或者服务做隔离和保护,这样可以提升网关的可用性;
  4. API 网关可以替代原本系统中的 Web 层,将 Web 层中的协议转换、认证、限流等功能挪入到 API 网关中,将服务聚合的逻辑下沉到服务层。

API 网关可以为 API 的调用提供便捷,也可以为将一些服务治理的功能独立出来,达到复用的目的,虽然在性能上可能会有一些损耗, 但是一般来说, 使用成熟的开源 API 网关组件,这些损耗都是可以接受的。所以,当你的微服务系统越来越复杂时,你可以考虑使用 API 网关作为整体系统的门面。

4.27 - CH28-分布式-跨地域

来想象这样一个场景: 你的垂直电商系统部署的 IDC 机房,在某一天发布了公告说,机房会在第二天凌晨做一次网络设备的割接,在割接过程中会不定时出现瞬间,或短时间网络中断。

机房网络的中断,肯定会对业务造成不利的影响,即使割接的时间在凌晨(业务的低峰期),作为技术负责人的你,也要尽量思考方案来规避隔离的影响。然而不幸的是,在现有的技术架构下,电商业务全都部署在一个 IDC 机房中,你并没有好的解决办法。

而 IDC 机房的可用性问题是整个系统的 阿喀琉斯之踵,一旦 IDC 机房像一些大厂一样,出现很严重的问题,就会对整体服务的可用性造成严重的影响。比如:

2016 年 7 月,北京联通整顿旗下 40 多个 IDC 机房中,不规范的接入情况,大批不合规接入均被断网,这一举动致使脉脉当时使用的蓝汛机房受到影响,脉脉宕机长达 15 个小时,著名的 A 站甚至宕机超过 48 个小时,损失可想而知。

而目前,单一机房部署的架构特点,决定了你的系统可用性受制于机房的可用性,也就是机房掌控了系统的生命线。所以,你开始思考,如何通过架构的改造,来进一步提升系统的可用性。在网上搜索解决方案和学习一些大厂的经验后,你发现「多机房部署」可以解决这个问题。

多机房部署的难点是什么

多机房部署的含义是: 在不同的 IDC 机房中,部署多套服务,这些服务共享同一份业务数据,并且都可以承接来自用户的流量。

这样,当其中某一个机房出现网络故障、火灾,甚至整个城市发生地震、洪水等大的不可抗的灾难时,你可以随时将用户的流量切换到其它地域的机房中,从而保证系统可以不间断地持续运行。这种架构听起来非常美好,但是在实现上却是非常复杂和困难的,那么它复杂在哪儿呢?

假如我们有两个机房 A 和 B 都部署了应用服务,数据库的主库和从库部署在 A 机房,那么机房 B 的应用如何访问到数据呢? 有两种思路。

一个思路是直接跨机房读取 A 机房的从库:

NAME

另一个思路是在机房 B 部署一个从库,跨机房同步主库的数据,然后机房 B 的应用就可以读取这个从库的数据了:

NAME

无论是哪一种思路, 都涉及到跨机房的数据传输, 这就对机房之间延迟情况有比较高的要求了。而机房之间的延迟,和机房之间的距离息息相关, 你可以记住几个数字:

  1. 北京同地双机房之间的专线延迟一般在 1ms~3ms。

    这个延迟会造成怎样的影响呢? 要知道,我们的接口响应时间需要控制在 200ms 之内,而一个接口可能会调用几次第三方 HTTP 服务,或者 RPC 服务。如果这些服务部署在异地机房,那么接口响应时间就会增加几毫秒,是可以接受的。

    一次接口可能会涉及几次的数据库写入,那么如果数据库主库在异地机房,那么接口的响应时间也会因为写入异地机房的主库,增加几毫秒到十几毫秒,也是可以接受的。

    但是,接口读取缓存和数据库的数量,可能会达到十几次甚至几十次,那么这就会增加几十毫秒甚至上百毫秒的延迟,就不能接受了。

  2. 国内异地双机房之间的专线延迟会在 50ms 之内。

    具体的延迟数据依据距离的不同而不同。比如,北京到天津的专线延迟,会在 10ms 之内;而北京到上海的延迟就会提高到接近 30ms;如果想要在北京和广州部署双机房,那么延迟就会到达 50ms 了。 在这个延迟数据下, 要想保证接口的响应时间在 200ms 之内,就要尽量减少跨机房的服务调用,更要避免跨机房的数据库和缓存操作了。

  3. 如果你的业务是国际化的服务,需要部署跨国的双机房,那么机房之间的延迟就更高了,依据各大云厂商的数据来看,比如,从国内想要访问部署在美国西海岸的服务,这个延迟会在 100ms~200ms 左右。在这个延迟下,就要避免数据跨机房同步调用,而只做异步的数据同步。

如果你正在考虑多机房部署的架构,那么这些数字都是至关重要的基础数据, 你需要牢牢记住, 避免出现跨机房访问数据造成性能衰减问题。

机房之间的数据延迟,在客观上是存在的,你没有办法改变,你可以做的,就是尽量避免数据延迟对于接口响应时间的影响。那么在数据延迟下, 你要如何设计多机房部署的方案呢?

逐步迭代多机房部署方案

1. 同城双活

制定多机房部署的方案不是一蹴而就的,而是不断迭代发展的。我在上面提到,同城机房之间的延时在 1ms~3ms 左右,对于跨机房调用的容忍度比较高,所以,这种同城双活的方案复杂度会比较低。

但是,它只能做到机房级别的容灾,无法做到城市级别的容灾。不过,相比于城市发生地震、洪水等自然灾害来说,机房网络故障、掉电出现的概率要大的多。所以,如果你的系统不需要考虑城市级别的容灾,一般做到同城双活就足够了。 那么,同城双活的方案要如何设计呢?

假设这样的场景: 你在北京有 A 和 B 两个机房,A 是联通的机房,B 是电信的机房,机房之间以专线连接,方案设计时,核心思想是,尽量避免跨机房的调用。 具体方案如下:

  • 首先,数据库的主库可以部署在一个机房中,比如部署在 A 机房中,那么 A 和 B 机房数据都会被写入到 A 机房中。然后,在 A、B 两个机房中各部署一个从库,通过主从复制的方式,从主库中同步数据,这样双机房的查询请求可以查询本机房的从库。一旦 A 机房发生故障,可以通过主从切换的方式,将 B 机房的从库提升为主库,达到容灾的目的。
  • 缓存也可以部署在两个机房中,查询请求也读取本机房的缓存,如果缓存中数据不存在,就穿透到本机房的从库中,加载数据。数据的更新可以更新双机房中的数据,保证数据的一致性。
  • 不同机房的 RPC 服务会向注册中心,注册不同的服务组,而不同机房的 RPC 客户端,也就是 Web 服务,只订阅同机房的 RPC 服务组,这样就可以实现 RPC 调用尽量发生在本机房内,避免跨机房的 RPC 调用。
NAME

你的系统肯定会依赖公司内的其他服务,比如审核,搜索等服务,如果这些服务也是双机房部署的,那么也需要尽量保证只调用本机房的服务,降低调用的延迟。

使用了同城双活架构之后,可以实现机房级别的容灾,服务的部署也能够突破单一机房的限制,但是,还是会存在跨机房写数据的问题,不过鉴于写数据的请求量不高,所以在性能上是可以容忍的。

2. 异地多活

上面这个方案,足够应对你目前的需要,但是,你的业务是不断发展的,如果有朝一日,你的电商系统的流量达到了京东或者淘宝的级别,那么你就要考虑,即使机房所在的城市发生重大的自然灾害,也要保证系统的可用性。 而这时,你需要采用异地多活的方案 (据我所知,阿里和饿了么采用的都是异地多活的方案)。

在考虑异地多活方案时,你首先要考虑异地机房的部署位置。它部署的不能太近,否则发生自然灾害时,很可能会波及。所以,如果你的主机房在北京,那么异地机房就尽量不要建设在天津,而是可以选择上海、广州这样距离较远的位置。但这就会造成更高的数据传输延迟,同城双活中,使用的跨机房写数据库的方案,就不合适了。

所以,在数据写入时,你要保证只写本机房的数据存储服务,再采取数据同步的方案,将数据同步到异地机房中。 一般来说,数据同步的方案有两种:

  • 一种基于存储系统的主从复制,比如 MySQL 和 Redis。也就是在一个机房部署主库,在异地机房部署从库,两者同步主从复制, 实现数据的同步。
  • 另一种是基于消息队列的方式。一个机房产生写入请求后,会写一条消息到消息队列,另一个机房的应用消费这条消息后,再执行业务处理逻辑,写入到存储服务中。

我建议你, 采用两种同步相结合的方式,比如,你可以基于消息的方式,同步缓存的数据、HBase 数据等。然后基于存储,主从复制同步 MySQL、Redis 等数据。

无论是采取哪种方案,数据从一个机房,传输到另一个机房都会有延迟,所以,你需要尽量保证用户在读取自己的数据时,读取数据主库所在的机房。为了达到这一点,你需要对用户做分片,让一个用户每次的读写都尽量在同一个机房中。同时,在数据读取和服务调用时,也要尽量调用本机房的服务。 这里有一个场景: 假如在电商系统中,用户 A 要查看所有订单的信息,而这些订单中,店铺的信息和卖家的信息很可能是存储在异地机房中,那么你应该优先保证服务调用,和数据读取在本机房中进行,即使读取的是跨机房从库的数据,会有一些延迟,也是可以接受的。

NAME

课程小结

本节课,为了提升系统的可用性和稳定性,我带你探讨了多机房部署的难点,以及同城双机房和异地多活的部署架构, 在这里,我想强调几个重点:

  • 不同机房的数据传输延迟,是造成多机房部署困难的主要原因,你需要知道,同城多机房的延迟一般在 1ms~3ms,异地机房的延迟在 50ms 以下,而跨国机房的延迟在 200ms 以下。
  • 同城多机房方案可以允许有跨机房数据写入的发生,但是数据的读取,和服务的调用应该尽量保证在同一个机房中。
  • 异地多活方案则应该避免跨机房同步的数据写入和读取,而是采取异步的方式,将数据从一个机房同步到另一个机房。

多机房部署是一个业务发展到一定规模,对于机房容灾有需求时,才会考虑的方案,能不做则尽量不要做。一旦你的团队决定做多机房部署,那么同城双活已经能够满足你的需求了,这个方案相比异地多活要简单很多。而在业界,很少有公司,能够搭建一套真正的异步多活架构,这是因为这套架构在实现时过于复杂, 所以,轻易不要尝试。

总之,架构需要依据系统的量级和对可用性、性能、扩展性的要求,不断演进和调整,盲目地追求架构的 「先进性」只能造成方案的复杂,增加运维成本,从而给你的系统维护带来不便。

4.28 - CH29-分布式-服务网格

在分布式服务篇的前几节课程中,我带你了解了在微服务化过程中,要使用哪些中间件解决服务之间通信和服务治理的问题,其中就包括:

  • 用 RPC 框架解决服务通信的问题;
  • 用注册中心解决服务注册,和发现的问题;
  • 使用分布式 Trace 中间件,排查跨服务调用慢请求;
  • 使用负载均衡服务器,解决服务扩展性的问题;
  • 在 API 网关中植入服务熔断、降级和流控等服务治理的策略。

经历了这几环之后,你的垂直电商系统基本上,已经完成了微服务化拆分的改造。不过,目前来看,你的系统使用的语言还是以 Java 为主,之前提到的服务治理的策略,和服务之间通信协议也是使用 Java 语言来实现的。

那么这会存在一个问题: 一旦你的团队中,有若干个小团队开始尝试使用 Go 或者 PHP,来开发新的微服务,那么在微服务化过程中,一定会受到挑战。

跨语言体系带来的挑战

其实,一个公司的不同团队,使用不同的开发语言是比较常见的。比如,微博的主要开发语言是 Java 和 PHP,近几年也有一些使用 Go 开发的系统。而使用不同的语言开发出来的微服务, 在相互调用时会存在两方面的挑战:

一方面,服务之间的通信协议上,要对多语言友好,要想实现跨语言调用,关键点是选择合适的序列化方式。 我给你举一个例子。

比如,你用 Java 开发一个 RPC 服务,使用的是 Java 原生的序列化方式,这种序列化方式对于其它语言并不友好,那么,你使用其它语言,调用这个 RPC 服务时,就很难解析序列化之后的二进制流。 所以,我建议你, 在选择序列化协议时,考虑序列化协议是否对多语言友好,比如,你可以选择 Protobuf、Thrift,这样一来,跨语言服务调用的问题,就可以很容易地解决了。

另一方面,使用新语言开发的微服务,无法使用之前积累的,服务治理的策略。比如说,RPC 客户端在使用注册中心,订阅服务的时候,为了避免每次 RPC 调用都要与注册中心交互,一般会在 RPC 客户端,缓存节点的数据。如果注册中心中的服务节点发生了变更,那么 RPC 客户端的节点缓存会得到通知,并且变更缓存数据。

而且,为了减少注册中心的访问压力,在 RPC 客户端上,我们一般会考虑使用多级缓存(内存缓存和文件缓存)来保证节点缓存的可用性。而这些策略在开始的时候,都是使用 Java 语言来实现的,并且封装在注册中心客户端里,提供给 RPC 客户端使用。如果更换了新的语言,这些逻辑就都要使用新的语言实现一套。

除此之外,负载均衡、熔断降级、流量控制、打印分布式追踪日志等等,这些服务治理的策略都需要重新实现,而使用其它语言重新实现这些策略无疑会带来巨大的工作量,也是中间件研发中,一个很大的痛点。

那么,你要如何屏蔽服务化架构中,服务治理的细节,或者说, 如何让服务治理的策略在多语言之间复用呢?

可以考虑将服务治理的细节,从 RPC 客户端中拆分出来,形成一个代理层单独部署。这个代理层可以使用单一的语言实现,所有的流量都经过代理层,来使用其中的服务治理策略。这是一种 关注点分离 的实现方式,也是 Service Mesh 的核心思想。

Service Mesh 是如何工作的

1. 什么是 Service Mesh

Service Mesh 主要处理服务之间的通信,它的主要实现形式就是在应用程序同主机上部署一个代理程序,一般来讲,我们将这个代理程序称为 Sidecar(边车),服务之间的通信也从之前的,客户端和服务端直连,变成了下面这种形式:

NAME

在这种形式下,RPC 客户端将数据包先发送给,与自身同主机部署的 Sidecar,在 Sidecar 中经过服务发现、负载均衡、服务路由、流量控制之后,再将数据发往指定服务节点的 Sidecar,在服务节点的 Sidecar 中,经过记录访问日志、记录分布式追踪日志、限流之后,再将数据发送给 RPC 服务端。

这种方式,可以把业务代码和服务治理的策略隔离开,将服务治理策略下沉,让它成为独立的基础模块。这样一来,不仅可以实现跨语言,服务治理策略的复用,还能对这些 Sidecar 做统一的管理。

目前,业界提及最多的 Service Mesh 方案当属 istio ,它的玩法是这样的:

NAME

它将组件分为 数据平面控制平面

  • 数据平面就是我提到的 Sidecar(Istio 使用 Envoy 作为 Sidecar 的实现)。
  • 控制平面主要负责服务治理策略的执行,在 Istio 中,主要分为 Mixer、Pilot 和 Istio-auth 三部分。

你可以先不了解每一部分的作用,只知道它们共同构成了服务治理体系就可以了。

然而,在 Istio 中,每次请求都需要经过控制平面,也就是说,每次请求都需要跨网络的调用 Mixer,这会极大地影响性能。

因此,国内大厂开源出来的 Service Mesh 方案中,一般只借鉴 Istio 的数据平面和控制平面的思路,然后将服务治理策略做到了 Sidecar 中,控制平面只负责策略的下发,这样就不需要每次请求都经过控制平面,性能上会改善很多。

2. 如何将流量转发到 Sidecar 中

在 Service Mesh 的实现中,一个主要的问题,是如何尽量无感知地引入 Sidecar 作为网络代理,也就是说,无论是数据流入还是数据流出时,都要将数据包重定向到 Sidecar 的端口上。 实现思路一般有两个:

第一种,使用 iptables 的方式来实现流量透明的转发 ,而 Istio 就默认了,使用 iptables 来实现数据包的转发。为了能更清晰的说明流量转发的原理,我们先简单地回顾一下什么是 iptables。

Iptables 是 Linux 内核中,防火墙软件 Netfilter 的管理工具,它位于用户空间,可以控制 Netfilter,实现地址转换的功能。在 iptables 中默认有五条链,你可以把这五条链,当作数据包流转过程中的五个步骤,依次为 PREROUTING,INPUT,FORWARD,OUTPUT 和 POSTROUTING。数据包传输的大体流程如下:

NAME

从图中可以看到,数据包以 PREROUTING 链作为入口,当数据包目的地为本机时,它们也都会流经到 OUTPUT 链。所以,我们可以在这两个链上,增加一些规则,将数据包重定向。我以 Istio 为例,带你看看如何使用 iptables 实现流量转发。

在 Istio 中,有一个叫做 istio-iptables.sh 的脚本,这个脚本在 Sidecar 被初始化的时候执行,主要是设置一些 iptables 规则。

我摘录了一些关键点来说明一下:

# 流出流量处理
iptables -t nat -N ISTIO_REDIRECT   # 增加 ISTIO_REDIRECT 链处理流出流量
iptables -t nat -A ISTIO_REDIRECT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" # 重定向流量到 Sidecar 的端口上
iptables -t nat -N ISTIO_OUTPUT # 增加 ISTIO_OUTPUT 链处理流出流量
iptables -t nat -A OUTPUT -p tcp -j ISTIO_OUTPUT # 将 OUTPUT 链的流量重定向到 ISTIO_OUTPUT 链上
for uid in ${PROXY_UID}; do
	# Sidecar 本身的流量不转发
    iptables -t nat -A ISTIO_OUTPUT -m owner --uid-owner "${uid}" -j RETURN 
done
for gid in ${PROXY_GID}; do
	# Sidecar 本身的流量不转发
    iptables -t nat -A ISTIO_OUTPUT -m owner --gid-owner "${gid}" -j RETURN  
done
iptables -t nat -A ISTIO_OUTPUT -j ISTIO_REDIRECT # 将 ISTIO_OUTPUT 链的流量转发到 ISTIO_REDIRECT

# 流入流量处理
iptables -t nat -N ISTIO_IN_REDIRECT  # 增加 ISTIO_IN_REDIRECT 链处理流入流量
iptables -t nat -A ISTIO_IN_REDIRECT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" # 将流入流量重定向到 Sidecar 端口
iptables -t ${table} -N ISTIO_INBOUND # 增加 ISTIO_INBOUND 链处理流入流量
iptables -t ${table} -A PREROUTING -p tcp -j ISTIO_INBOUND # 将 PREROUTING 的流量重定向到 ISTIO_INBOUND 链
iptables -t nat -A ISTIO_INBOUND -p tcp --dport "${port}" -j ISTIO_IN_REDIRECT # 将 ISTIO_INBOUND 链上指定目的端口的流量重定向到 ISTIO_IN_REDIR

假设服务的节点部署在 9080 端口上,Sidecar 开发的端口是 15001,那么流入流量的流向如下:

NAME

Iptables 方式的优势在于, 对于业务完全透明,业务甚至不知道有 Sidecar 存在,这样会减少业务接入的时间。不过,它也有缺陷,那就是它是在高并发下,性能上会有损耗,因此国内大厂采用了另外一种方式: 轻量级客户端。

在这种方式下,RPC 客户端会通过配置的方式,知道 Sidecar 的部署端口,然后通过一个轻量级客户端,将调用服务的请求发送给 Sidecar,Sidecar 在转发请求之前,先执行一些服务治理的策略,比如说,从注册中心中,查询到服务节点信息并且缓存起来,然后从服务节点中,使用某种负载均衡的策略选出一个节点等等。

请求被发送到服务端的 Sidecar 上后,然后在服务端记录访问日志,和分布式追踪日志,再把请求转发到真正的服务节点上。当然,服务节点在启动时,会委托服务端 Sidecar,向注册中心注册节点,Sidecar 也就知道了真正服务节点部署的端口是多少。整个请求过程如图所示:

NAME

当然,除了 iptables 和轻量级客户端两种方式外,目前在探索的方案还有 Cilium,这个方案可以从 Socket 层面实现请求的转发,也就可以避免 iptables 方式在性能上的损耗。 在这几种方案中,我建议你使用轻量级客户端的方式, 这样虽然会有一些改造成本,但是却在实现上最简单,可以快速的让 Service Mesh 在你的项目中落地。

当然,无论采用哪种方式,你都可以实现将 Sidecar 部署到,客户端和服务端的调用链路上,让它代理进出流量,这样,你就可以使用运行在 Sidecar 中的服务治理的策略了。至于这些策略我在前面的课程中都带你了解过(你可以回顾 23 至 26 讲的课程),这里就不再赘述了。

与此同时,我也建议你了解目前业界一些开源的 Service Mesh 框架,这样在选择方案时可以多一些选择。目前在开源领域比较成熟的 Service Mesh 框架有下面几个,你可以通过阅读它们的文档来深入了解,作为本节课的引申阅读。

  • Istio 这个框架在业界最为著名,它提出了数据平面和控制平面的概念,是 Service Mesh 的先驱,缺陷就是刚才提到的 Mixer 的性能问题。
  • Linkerd 是第一代的 Service Mesh,使用 Scala 语言编写,其劣势就是内存的占用。
  • SOFAMesh 是蚂蚁金服开源的 Service Mesh 组件,在蚂蚁金服已经有大规模落地的经验。

课程小结

本节课,为了解决跨语言场景下,服务治理策略的复用问题,我带你了解了什么是 Service Mesh 以及如何在实际项目中落地,你需要的重点内容如下:

  1. Service Mesh 分为数据平面和控制平面。数据平面主要负责数据的传输;控制平面用来控制服务治理策略的植入。出于性能的考虑,一般会把服务治理策略植入到数据平面中,控制平面负责服务治理策略数据的下发。
  2. Sidecar 的植入方式目前主要有两种实现方式,一种是使用 iptables 实现流量的劫持;另一种是通过轻量级客户端来实现流量转发。

目前,在一些大厂中,比如微博、蚂蚁金服,Service Mesh 已经开始在实际项目中大量的落地实践,而我建议你持续关注这项技术。它本身是一种将业务与通信基础设施分离的技术,如果你的业务上遇到多语言环境下,服务治理的困境,如果你的遗留服务,需要快速植入服务治理策略,如果你想要将你在服务治理方面积累的经验,快速地与其他团队共享,那么 Service Mesh 就是你的一个不错的选择。

4.29 - CH30-维护-后端监控

在一个项目的生命周期里,运行维护占据着很大的比重,在重要性上,它几乎与项目研发并驾齐驱。而在系统运维过程中,能够及时地发现问题并解决问题,是每一个团队的本职工作。所以,你的垂直电商系统在搭建之初,运维团队肯定完成了对于机器 CPU、内存、磁盘、网络等基础监控,期望能在出现问题时,及时地发现并且处理。你本以为万事大吉,却没想到系统在运行过程中,频频得到用户的投诉,原因是:

  • 使用的数据库主从延迟变长,导致业务功能上出现了问题;
  • 接口的响应时间变长,用户反馈商品页面出现空白页;
  • 系统中出现大量错误,影响了用户的正常使用。

这些问题,你本应该及时发现并处理的。但现实是,你只能被动地在问题被用户反馈后,手忙脚乱地修复。这时,你的团队才意识到,要想快速地发现和定位业务系统中出现的问题,必须搭建一套完善的服务端监控体系。正所谓「道路千万条,监控第一条,监控不到位,领导两行泪」。不过,在搭建的过程中,你的团队又陷入了困境:

  • 首先,监控的指标要如何选择呢?
  • 采集这些指标可以有哪些方法和途径呢?
  • 指标采集到之后又要如何处理和展示呢?

这些问题,一环扣一环,关乎着系统的稳定性和可用性,而本节课,我就带你解决这些问题,搭建一套服务端监控体系。

监控指标如何选择

你在搭建监控系统时,所面临的第一个问题就是,选择什么样的监控指标,也就是监控什么。有些同学在给一个新的系统,设定监控指标的时候,会比较迷茫,不知道从哪方面入手。其实,有一些成熟的理论和套路,你可以直接拿来使用。比如,谷歌针对分布式系统监控的经验总结,四个黄金信号(Four Golden Signals)。它指的是,在服务层面一般需要监控四个指标, 分别是延迟,通信量、错误和饱和度。

  • 延迟指的是请求的响应时间。比如,接口的响应时间、访问数据库和缓存的响应时间。
  • 通信量可以理解为吞吐量,也就是单位时间内,请求量的大小。比如,访问第三方服务的请求量,访问消息队列的请求量。
  • 错误表示当前系统发生的错误数量。这里需要注意的是, 我们需要监控的错误既有显示的,比如在监控 Web 服务时,出现 4xx5xx 的响应码;也有隐示的,比如,Web 服务虽然返回的响应码是 200,但是却发生了一些和业务相关的错误(出现了数组越界的异常或者空指针异常等),这些都是错误的范畴。
  • 饱和度指的是服务或者资源到达上限的程度(也可以说是服务或者资源的利用率),比如说 CPU 的使用率,内存使用率,磁盘使用率,缓存数据库的连接数等等。

这四个黄金信号提供了通用的监控指标,除此之外,你还可以借鉴 RED 指标体系。 这个体系,是四个黄金信号中衍生出来的,其中,R 代表请求量(Request rate),E 代表错误(Error),D 代表响应时间(Duration),少了饱和度的指标。你可以把它当作一种简化版的通用监控指标体系。

当然,一些组件或者服务还有独特的指标,这些指标也是需要你特殊关注的。比如,课程中提到的数据库主从延迟数据、消息队列的堆积情况、缓存的命中率等等。我把高并发系统中常见组件的监控指标,整理成了一张表格,其中没有包含诸如 CPU、内存、网络、磁盘等基础监控指标,只是业务上监控指标,主要方便你在实际工作中参考使用。

NAME

选择好了监控指标之后,你接下来要考虑的,是如何从组件或者服务中,采集到这些指标,也就是指标数据采集的问题。

如何采集数据指标

说到监控指标的采集,我们一般会依据采集数据源的不同,选用不同的采集方式,总结起来,大概有以下几种类型:

首先, Agent 是一种比较常见的,采集数据指标的方式。

我们通过在数据源的服务器上,部署自研或者开源的 Agent,来收集收据,发送给监控系统,实现数据的采集。在采集数据源上的信息时,Agent 会依据数据源上,提供的一些接口获取数据,我给你举两个典型的例子。

比如,你要从 Memcached 服务器上,获取它的性能数据,那么,你就可以在 Agent 中,连接这个 Memcached 服务器,并且发送一个 stats 命令,获取服务器的统计信息。然后,你就可以从返回的信息中,挑选重要的监控指标,发送给监控服务器,形成 Memcached 服务的监控报表。你也可以从这些统计信息中,看出当前 Memcached 服务器,是否存在潜在的问题。下面是我推荐的,一些重要的状态项,你可以参考使用。

STAT cmd_get 201809037423    // 计算查询的 QPS
STAT cmd_set 16174920166     // 计算写入的 QPS
STAT get_hits 175226700643   // 用来计算命中率,命中率 = get_hits/cmd_get
STAT curr_connections 1416   // 当前连接数
STAT bytes 3738857307        // 当前内存占用量
STAT evictions 11008640149   // 当前被 memcached 服务器剔除的 item 数量,如果这个数量过大 (比如例子中的这个数值),那么代表当前 Memcached 容量不足或者 Memcached Slab Class 分配有问题

另外,如果你是 Java 的开发者,那么一般使用 Java 语言开发的中间件,或者组件,都可以通过 JMX 获取统计或者监控信息。比如,在 19 讲中,我提到可以使用 JMX,监控 Kafka 队列的堆积数,再比如,你也可以通过 JMX 监控 JVM 内存信息和 GC 相关的信息。

另一种很重要的数据获取方式,是在代码中埋点。

这个方式与 Agent 的不同之处在于,Agent 主要收集的是组件服务端的信息,而埋点则是从客户端的角度,来描述所使用的组件,和服务的性能和可用性。那么埋点的方式怎么选择呢?

你可以使用 25 讲分布式 Trace 组件中,提到的面向切面编程的方式;也可以在资源客户端中,直接计算调用资源或者服务的耗时、调用量、慢请求数,并且发送给监控服务器。

这里你需要注意一点, 由于调用缓存、数据库的请求量会比较高,一般会单机也会达到每秒万次,如果不经过任何优化,把每次请求耗时都发送给监控服务器,那么,监控服务器会不堪重负。所以,我们一般会在埋点时,先做一些汇总。比如,每隔 10 秒汇总这 10 秒内,对同一个资源的请求量总和、响应时间分位值、错误数等,然后发送给监控服务器。这样,就可以大大减少发往监控服务器的请求量了。

最后, 日志也是你监控数据的重要来源之一。

你所熟知的 Tomcat 和 Nginx 的访问日志,都是重要的监控日志。你可以通过开源的日志采集工具,将这些日志中的数据发送给监控服务器。目前,常用的日志采集工具有很多,比如,Apache Flume、Fluentd 和 Filebeat,你可以选择一种熟悉的使用。比如在我的项目中,我会倾向于使用 Filebeat 来收集监控日志数据。

监控数据的处理和存储

在采集到监控数据之后,你就可以对它们进行处理和存储了,在此之前,我们一般会先用消息队列来承接数据,主要的作用是削峰填谷,防止写入过多的监控数据,让监控服务产生影响。

与此同时,我们一般会部署两个队列处理程序,来消费消息队列中的数据。

一个处理程序接收到数据后,把数据写入到 Elasticsearch,然后通过 Kibana 展示数据,这份数据主要是用来做原始数据的查询;

另一个处理程序是一些流式处理的中间件,比如,Spark、Storm。它们从消息队列里,接收数据后会做一些处理,这些处理包括:

  • 解析数据格式,尤其是日志格式 从里面提取诸如请求量、响应时间、请求 URL 等数据;

  • 对数据做一些聚合运算

    比如,针对 Tomcat 访问日志,可以计算同一个 URL 一段时间之内的请求量、响应时间分位值、非 200 请求量的大小等等。

  • 将数据存储在时间序列数据库中

    这类数据库的特点是,可以对带有时间标签的数据,做更有效的存储,而我们的监控数据恰恰带有时间标签,并且按照时间递增,非常适合存储在时间序列数据库中。目前业界比较常用的时序数据库有 InfluxDB、OpenTSDB、Graphite,各大厂的选择均有不同,你可以选择一种熟悉的来使用。

最后, 你就可以通过 Grafana 来连接时序数据库,将监控数据绘制成报表,呈现给开发和运维的同学了。

NAME

至此,你和你的团队,也就完成了垂直电商系统,服务端监控系统搭建的全过程。这里我想再多说一点,我们从不同的数据源中采集了很多的指标,最终在监控系统中一般会形成以下几个报表,你在实际的工作中可以参考借鉴:

  1. 访问趋势报表

    这类报表接入的是 Web 服务器,和应用服务器的访问日志,展示了服务整体的访问量、响应时间情况、错误数量、带宽等信息。它主要反映的是,服务的整体运行情况,帮助你来发现问题。

  2. 性能报表

    这类报表对接的是资源和依赖服务的埋点数据,展示了被埋点资源的访问量和响应时间情况。它反映了资源的整体运行情况,当你从访问趋势报表发现问题后,可以先从性能报表中,找到究竟是哪一个资源或者服务出现了问题。

  3. 资源报表

    这类报表主要对接的是,使用 Agent 采集的,资源的运行情况数据。当你从性能报表中,发现某一个资源出现了问题,那么就可以进一步从这个报表中,发现资源究竟出现了什么问题,是连接数异常增高,还是缓存命中率下降。这样可以进一步帮你分析问题的根源,找到解决问题的方案。

课程小结

本节课,我带你了解了,服务端监控搭建的过程,在这里,你需要了解以下几个重点:

  1. 耗时、请求量和错误数是三种最通用的监控指标,不同的组件还有一些特殊的监控指标,你在搭建自己的监控系统的时候可以直接使用;
  2. Agent、埋点和日志是三种最常见的数据采集方式;
  3. 访问趋势报表用来展示服务的整体运行情况,性能报表用来分析资源或者依赖的服务是否出现问题,资源报表用来追查资源问题的根本原因。这三个报表共同构成了你的服务端监控体系。

总之,监控系统是你发现问题,排查问题的重要工具,你应该重视它,并且投入足够的精力来不断地完善它。只有这样,才能不断地提高对系统运维的掌控力,降低故障发生的风险。

4.30 - CH31-维护-应用监控

上一节课中,我带你了解了服务端监控搭建的过程。有了监控报表之后,你的团队在维护垂直电商系统时,就可以更早地发现问题,也有直观的工具辅助你们分析排查问题了。

不过,你很快发现,有一些问题,服务端的监控报表无法排查,甚至无法感知。比如,有用户反馈创建订单失败,但是从服务端的报表来看,并没有什么明显的性能波动,从存储在 Elasticsearch 里的原始日志中,甚至没有找到这次创建订单的请求。这有可能是客户端有 Bug,或者网络抖动导致创建订单的请求并没有发送到服务端。

再比如,有些用户会反馈,使用长城宽带打开商品详情页面特别慢,甚至出现 DNS 解析失败的情况。那么,当我们遇到这类问题时,要如何排查和优化呢?

这里面涉及一个概念叫应用性能管理(Application Performance Management,简称 APM),它的含义是: 对应用各个层面做全方位的监测,期望及时发现可能存在的问题,并加以解决,从而提升系统的性能和可用性。

你是不是觉得和之前讲到的服务端监控很相似?其实,服务端监控的核心关注点是后端服务的性能和可用性,而应用性能管理的核心关注点是终端用户的使用体验,也就是你需要衡量,从客户端请求发出开始,到响应数据返回到客户端为止,这个端到端的整体链路上的性能情况。

如果你能做到这一点,那么无论是订单创建问题的排查,还是长城宽带用户页面打开缓慢的问题,都可以通过这套监控来发现和排查。 那么,如何搭建这么一套端到端的监控体系呢?

如何搭建 APM 系统

与搭建服务端监控系统类似,在搭建端到端的,应用性能管理系统时,我们也可以从数据的采集、存储和展示几个方面来思考。

首先,在数据采集方面,我们可以采用类似 Agent 的方式,在客户端上植入 SDK,由 SDK 负责采集信息,并且经过采样之后,通过一个固定的接口,定期发送给服务端。这个固定接口和服务端,我们可以称为 APM 通道服务。

虽然客户端需要监控的指标很多,比如监控网络情况,监控客户端卡顿情况、垃圾收集数据等等,但我们可以定义一种通用的数据采集格式。

比如,在我之前的公司里,采集的数据包含下面几个部分,SDK 将这几部分数据转换成 JSON 格式后,就可以发送给 APM 通道服务了。这几部分数据格式,你可以在搭建自己的 APM 系统时,直接拿来参考。

  • 系统部分:包括数据协议的版本号,以及下面提到的消息头、端消息体、业务消息体的长度;
  • 消息头:主要包括应用的标识(appkey),消息生成的时间戳,消息的签名以及消息体加密的秘钥;
  • 端消息体:主要存储客户端的一些相关信息,主要有客户端版本号、SDK 版本号、IDFA、IDFV、IMEI、机器型号、渠道号、运营商、网络类型、操作系统类型、国家、地区、经纬度等等。由于这些信息有些比较敏感,所以我们一般会对信息加密;
  • 业务消息体:也就是真正要采集的数据,这些数据也需要加密。

加密的方法是这样的: 我们首先会分配给这个应用,一对 RSA 公钥和私钥,然后 SDK 在启动的时候,先请求一个策略服务,获取 RSA 公钥。在加密时,客户端会随机生成一个对称加密的秘钥 Key,端消息体和业务消息体,都会使用这个秘钥来加密。那么数据发到 APM 通道服务后,要如何解密呢?

客户端会使用 RSA 的公钥对秘钥加密,存储在消息头里面(也就是上面提到的,消息体加密秘钥),然后 APM 通道服务使用 RSA 秘钥,解密得到秘钥,就可以解密得到端消息体和业务消息体的内容了。

最后,我们把消息头、端消息体、业务消息体还有消息头中的时间戳组装起来,用 MD5 生成摘要后,存储在消息头中(也就是消息的签名)。这样,APM 通道服务在接收到消息后,可以使用同样的算法生成摘要,并且与发送过来的摘要比对,以防止消息被篡改。

数据被采集到 APM 通道服务之后,我们先对 JSON 消息做解析,得到具体的数据,然后发送到消息队列里面。从消息队列里面消费到数据之后,会写一份数据到 Elasticsearch 中,作为原始数据保存起来,再写一份到统计平台,以形成客户端的报表。

NAME

有了这套 APM 通道服务,我们就可以将从客户端上采集到的信息,通过统一的方式上报到服务端做集中处理。这样一来,你就可以收集到客户端上的性能数据和业务数据,能够及时地发现问题了。

那么问题来了:虽然你搭建了客户端监控系统,但是在我们电商系统客户端中可以收集到用户网络数据,卡顿数据等等,你是要把这些信息都监控到位,还是有所侧重呢?要知道,监控的信息不明确,会给问题排查带来不便,而这就是我们接下来探究的问题,也就是你到底需要监控用户的哪些信息。

需要监控用户的哪些信息

在我看来,搭建端到端的监控体系的首要目标,是解决如何监控客户端网络的问题,这是因为我们遇到的客户端问题, 大部分的原因还是出在客户端网络上。

在中国复杂的网络环境下,大的运营商各行其是,各自为政,在不同的地区的链路质量各有不同,而小的运营商又鱼龙混杂,服务质量得不到保障。我给你说一个典型的问题。

之前在讲解 DNS 时,我曾经提到在做 DNS 解析的时候,为了缩短查询的链路,首先会查询运营商的 Local DNS,但是 Local DNS 这个东西很不靠谱,有些小的运营商为了节省流量,他会把一些域名解析到内容缓存服务器上,甚至会解析到广告或者钓鱼网站上去,这就是域名劫持。也有一些运营商它比较懒,自己不去解析域名,而是把解析请求,转发到别的运营商上,这就导致权威 DNS 收到请求的来源 IP 的运营商,是不正确的。这样一来,解析的 IP 和请求源,会来自不同的运营商,形成跨网的流量,导致 DNS 解析时间过长。这些需要我们进行实时地监控,以尽快地发现问题,反馈给运营商来解决。

那么,我们如何采集网络数据呢? 一般来说,我们会用埋点的方式,将网络请求的每一个步骤耗时情况、是否发生错误,都打印下来,我以安卓系统为例,解释一下是如何做的。

安卓一般会使用 OkHttpClient 来请求接口数据,而 OkHttpClient 提供了 EventListner 接口,可以让调用者接收到网络请求事件,比如,开始解析 DNS 事件,解析 DNS 结束的事件等等。那么你就可以埋点计算出,一次网络请求的各个阶段的耗时情况。我写了一段具体的示例代码,计算了一次请求的 DNS 解析时间,你可以拿去参考。

public class HttpEventListener extends EventListener {
    final static AtomicLong nextCallId = new AtomicLong(1L);
    private final long callId;
    private long dnsStartTime;
    private HttpUrl url ;
    public HttpEventListener(HttpUrl url) {
        this.callId = nextCallId.getAndIncrement(); // 初始化唯一标识这次请求的 ID
        this.url = url;
    }
    
    @Override
    public void dnsStart(Call call, String domainName) {
        super.dnsStart(call, domainName);
        this.dnsStartTime = System.nanoTime(); // 记录 dns 开始时间
    }
    @Override
    public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {
        super.dnsEnd(call, domainName, inetAddressList);
        System.out.println("url: " + url.host() + ", Dns time: " + (System.nanoTime() - dnsStartTime)); // 计算 dns 解析的时间
    }
}

有了这个 EventListner,你就可以在初始化 HttpClient 的时候把它注入进去,代码如下:

OkHttpClient.Builder builder = new OkHttpClient.Builder()
        .eventListenerFactory(new Factory() {
            @Override
            public EventListener create(Call call) {
                return new HttpEventListener(call.request().url());
            }
        });

这样,我们可以得出一次请求过程中,经历的一系列过程的时间,其中主要包括下面几项。

  1. 等待时间:异步调用时,请求会首先缓存在本地的队列里面,由专门的 I/O 线程负责,那么在 I/O 线程真正处理请求之前,会有一个等待的时间。
  2. DNS 时间:域名解析时间。
  3. 握手时间:TCP 三次握手的时间。
  4. SSL 时间:如果服务是 HTTPS 服务,那么就会有一个 SSL 认证的时间。
  5. 发送时间:请求包被发送出去的时间。
  6. 首包时间:服务端处理后,客户端收到第一个响应包的时间。
  7. 包接收时间:我们接收到所有数据的时间。
NAME

有了这些数据之后,我们可以通过上面提到的 APM 通道服务,发送给服务端,这样服务端和客户端的同学,就可以从 Elasticsearch 中,查询到原始的数据,也可以对数据做一些聚合处理、统计分析之后,形成客户端请求监控的报表。这样,我们就可以有针对性地对 HTTP 请求的某一个过程做优化了。

而对于用户网络的监控,可以给你带来三方面的价值。

首先,这种用户网络监控的所有监控数据均来自客户端,是用户访问数据实时上报,因此能够准确、真实、实时地反应用户操作体验。

再者,它是我们性能优化的指向标,当做业务架构改造、服务性能优化、网络优化等任何优化行为时,可以反馈用户性能数据,引导业务正向优化接口性能、可用性等指标。

最后,它也能帮助我们监控 CDN 链路质量。之前的 CDN 的监控,严重依赖 CDN 厂商,这有一个问题是:CDN 无法从端上获取到全链路的监控数据,有些时候,客户端到 CDN 的链路出了问题,CDN 厂商是感知不到的,而客户端监控弥补了这方面的缺陷,并且可以通过告警机制督促 CDN 及时优化调整问题线路。

除了上报网络数据之外,我们还可以上报一些异常事件的数据,比如你的垂直电商系统可能会遇到以下一些异常情况。

  • 登录失败
  • 下单失败
  • 浏览商品信息失败
  • 评论列表加载不出来
  • 无法评分留言

你在业务逻辑的代码中,都可以检测到这些异常数据,当然,也可以通过 APM 通道服务,上传到服务端,这样方便服务端和客户端的同学一起来排查问题,也能给你的版本灰度提供数据的支持。

总的来说,如果说搭建的系统是骨架,那么具体监控的数据就是灵魂,因为数据是监控的主题内容,系统只是呈现数据的载体。所以,你需要在系统运维的过程中不断完善对数据的收集,这也是对你的监控系统不断升级完善的过程。

课程小结

以上就是本节课的全部内容了。本节课,我主要带你了解了,如何搭建一个端到端的 APM 监控系统,你需要了解的重点是:

  1. 从客户端采集到的数据可以用通用的消息格式,上传到 APM 服务端,服务端将数据存入到 Elasticsearch 中,以提供原始日志的查询,也可以依据这些数据形成客户端的监控报表;
  2. 用户网络数据是我们排查客户端,和服务端交互过程的重要数据,你可以通过代码的植入,来获取到这些数据;
  3. 无论是网络数据,还是异常数据,亦或是卡顿、崩溃、流量、耗电量等数据,你都可以通过把它们封装成 APM 消息格式,上传到 APM 服务端,这些用户在客户端上留下的踪迹可以帮助你更好地优化用户的使用体验。

总而言之,监测和优化用户的使用体验是应用性能管理的最终目标。 然而,服务端的开发人员往往会陷入一个误区,认为我们将服务端的监控做好,保证接口性能和可用性足够好就好了。事实上,接口的响应时间只是我们监控系统中很小的一部分,搭建一套端到端的全链路的监控体系,才是你的监控系统的最终形态。

4.31 - CH32-维护-链路压测

经过两节课的学习,我们已经搭建了服务端和客户端的监控,通过监控的报表和一些报警规则的设置,你可以实时地跟踪和解决垂直电商系统中出现的问题了。不过,你不能掉以轻心,因为监控只能发现目前系统中已经存在的问题,对于未来可能发生的性能问题是无能为力的。

一旦你的系统流量有大的增长,比如类似「双十一」的流量,那么你在面临性能问题时就可能会手足无措。为了解决后顾之忧,你需要了解在流量增长若干倍的时候,系统的哪些组件或者服务会成为整体系统的瓶颈点,这时你就需要做一次全链路的压力测试。

那么,什么是压力测试呢?要如何来做全链路的压测呢?这两个问题就是本节课重点讲解的内容。

什么是压力测试

压力测试(简称为压测)这个名词儿,你在业界的分享中一定听过很多次,当然了,你也可能在项目的研发过程中做过压力测试,所以,对于你来说,压力测试并不陌生。

不过,我想让你回想一下,自己是怎么做压力测试的?是不是像很多同学一样:先搭建一套与正式环境功能相同的测试环境,并且导入或者生成一批测试数据,然后在另一台服务器,启动多个线程并发地调用需要压测的接口(接口的参数一般也会设置成相同的,比如,想要压测获取商品信息的接口,那么压测时会使用同一个商品 ID)。最后,通过统计访问日志,或者查看测试环境的监控系统,来记录最终压测 QPS 是多少之后,直接交差?

这么做压力测试其实是不正确的,错误之处主要有以下几点:

  1. 首先,做压力测试时,最好使用线上的数据和线上的环境,因为,你无法确定自己搭建的测试环境与正式环境的差异,是否会影响到压力测试的结果;

  2. 其次,压力测试时不能使用模拟的请求,而是要使用线上的流量。你可以通过拷贝流量的方式,把线上流量拷贝一份到压力测试环境。因为模拟流量的访问模型,和线上流量相差很大,会对压力测试的结果产生比较大的影响。

    比如,你在获取商品信息的时候,线上的流量会获取不同商品的数据,这些商品的数据有的命中了缓存,有的没有命中缓存。如果使用同一个商品 ID 来做压力测试,那么只有第一次请求没有命中缓存,而在请求之后会将数据库中的数据回种到缓存,后续的请求就一定会命中缓存了,这种压力测试的数据就不具备参考性了。

  3. 不要从一台服务器发起流量,这样很容易达到这台服务器性能瓶颈,从而导致压力测试的 QPS 上不去,最终影响压力测试的结果。而且,为了尽量真实地模拟用户请求,我们倾向于把流量产生的机器,放在离用户更近的位置,比如放在 CDN 节点上。如果没有这个条件,那么可以放在不同的机房中,这样可以尽量保证压力测试结果的真实性。

之所以有很多同学出现这个问题,主要是对压力测试的概念没有完全理解,以为只要是使用多个线程并发的请求服务接口,就算是对接口进行压力测试了。

那么究竟什么是压力测试呢? 压力测试指的是,在高并发大流量下,进行的测试,测试人员可以通过观察系统在峰值负载下的表现,从而找到系统中存在的性能隐患。

与监控一样,压力测试是一种常见的,发现系统中存在问题的方式,也是保障系统可用性和稳定性的重要手段。而在压力测试的过程中,我们不能只针对某一个核心模块来做压测,而需要将接入层、所有后端服务、数据库、缓存、消息队列、中间件以及依赖的第三方服务系统及其资源,都纳入压力测试的目标之中。因为,一旦用户的访问行为增加,包含上述组件服务的整个链路都会受到不确定的大流量的冲击,因此,它们都需要依赖压力测试来发现可能存在的性能瓶颈, 这种针对整个调用链路执行的压力测试也称为「全链路压测」。

由于在互联网项目中,功能迭代的速度很快,系统的复杂性也变得越来越高,新增加的功能和代码很可能会成为新的性能瓶颈点。也许半年前做压力测试时,单台机器可以承担每秒 1000 次请求,现在很可能就承担每秒 800 次请求了。所以,压力测试应该作为系统稳定性保障的常规手段,周期性地进行。

但是,通常做一次全链路压力测试,需要联合 DBA、运维、依赖服务方、中间件架构等多个团队,一起协调进行,无论是人力成本还是沟通协调的成本都比较高。同时,在压力测试的过程中,如果没有很好的监控机制,那么还会对线上系统造成不利的影响。 为了解决这些问题,我们需要搭建一套自动化的全链路压测平台,来降低成本和风险。

如何搭建全链路压测平台

搭建全链路压测平台,主要有两个关键点。

一点是流量的隔离。由于压力测试是在正式环境进行,所以需要区分压力测试流量和正式流量,这样可以针对压力测试的流量做单独的处理。

另一点是风险的控制。也就是,尽量避免压力测试对于正常访问用户的影响,因此,一般来说全链路压测平台需要包含以下几个模块:

  • 流量构造和产生模块;
  • 压测数据隔离模块;
  • 系统健康度检查和压测流量干预模块。

整体压测平台的架构图可以是下面这样的:

NAME

为了让你能够更清晰地了解每一个模块是如何实现的,方便你来设计适合自身业务的全链路压测平台,我会对压测平台的每一个模块做更细致地介绍。先来看看压力测试的流量是如何产生的。

压测数据的产生

一般来说,我们系统的入口流量是来自于客户端的 HTTP 请求,所以,我们会考虑在系统高峰期时,将这些入口流量拷贝一份,在经过一些流量清洗的工作之后(比如过滤一些无效的请求),将数据存储在像是 HBase、MongoDB 这些 NoSQL 存储组件,或者亚马逊 S3 这些云存储服务中,我们称之为 流量数据工厂

这样,当我们要压测的时候,就可以从这个工厂中获取数据,将数据切分多份后下发到多个压测节点上了,在这里,我想强调几个,你需要特殊注意的点。

首先,我们可以使用多种方式来实现流量的拷贝。最简单的一种方式:直接拷贝负载均衡服务器的访问日志,数据就以文本的方式写入到流量数据工厂中,但是这样产生的数据在发起压测时,需要自己写解析的脚本来解析访问日志,会增加压测时候的成本,不太建议使用。

另一种方式:通过一些开源的工具来实现流量的拷贝。这里,我推荐一款轻型的流量拷贝工具 GoReplay,它可以劫持本机某一个端口的流量,将它们记录在文件中,传送到流量数据工厂中。在压测时,你也可以使用这个工具进行加速的流量回放,这样就可以实现对正式环境的压力测试了。

其次,如上文中提到的,我们在下发压测流量时,需要保证下发流量的节点与用户更加接近,起码不能和服务部署节点在同一个机房中,这样可以尽量保证压测数据的真实性。

另外,我们还需要对 压测流量染色,也就是增加压测标记。在实际项目中,我会在 HTTP 的请求头中增加一个标记项,比如说叫做 is stress test,在流量拷贝之后,批量在请求中增加这个标记项,再写入到数据流量工厂中。

数据如何隔离

将压测流量拷贝下来的同时,我们也需要考虑对系统做改造,以实现压测流量和正式流量的隔离,这样一来就会尽量避免压测对线上系统的影响,一般来说,我们需要做两方面的事情。

一方面,针对读取数据的请求(一般称之为下行流量),我们会针对某些不能压测的服务或者组件,做 Mock 或者特殊的处理。举个例子。

在业务开发中,我们一般会依据请求,记录用户的行为,比如,用户请求了某个商品的页面,我们会记录这个商品多了一次浏览的行为,这些行为数据会写入一份单独的大数据日志中,再传输给数据分析部门,形成业务报表给到产品或者老板做业务的分析决策。

在压测的时候,肯定会增加这些行为数据,比如原本一天商品页面的浏览行为是一亿次,而压测之后变成了十亿次,这样就会对业务报表产生影响,影响后续的产品方向的决策。因此,我们对于这些压测产生的用户行为做特殊处理,不再记录到大数据日志中。

再比如,我们系统会依赖一些推荐服务,推荐一些你可能感兴趣的商品,但是这些数据的展示有一个特点就是,展示过的商品就不再会被推荐出来。如果你的压测流量经过这些推荐服务,大量的商品就会被压测流量请求到,线上的用户就不会再看到这些商品了,也就会影响推荐的效果。

所以,我们需要 Mock 这些推荐服务,让不带有压测标记的请求经过推荐服务,而让带有压测标记的请求经过 Mock 服务。搭建 Mock 服务,你需要注意一点:这些 Mock 服务最好部署在真实服务所在的机房,这样可以尽量模拟真实的服务部署结构,提高压测结果的真实性。

另一方面,针对写入数据的请求(一般称之为上行流量),我们会把压测流量产生的数据,写入到影子库,也就是和线上数据存储,完全隔离的一份存储系统中。针对不同的存储类型,我们会使用不同的影子库的搭建方式:

  1. 如果数据存储在 MySQL 中,我们可以在同一个 MySQL 实例,不同的 Schema 中创建一套和线上相同的库表结构,并且把线上的数据也导入进来。
  2. 而如果数据是放在 Redis 中,我们对压测流量产生的数据,增加一个统一的前缀,存储在同一份存储中。
  3. 还有一些数据会存储在 Elasticsearch 中,针对这部分数据,我们可以放在另外一个单独的索引表中。

通过对下行流量的特殊处理以及对上行流量增加影子库的方式,我们就可以实现压测流量的隔离了。

压力测试如何实施

在拷贝了线上流量和完成了对线上系统的改造之后,我们就可以进行压力测试的实施了。在此之前,一般会设立一个压力测试的目标,比如说,整体系统的 QPS 需要达到每秒 20 万。

不过,在压测时,不会一下子把请求量增加到每秒 20 万次,而是按照一定的步长(比如每次压测增加一万 QPS),逐渐地增加流量。在增加一次流量之后,让系统稳定运行一段时间,观察系统在性能上的表现。如果发现依赖的服务或者组件出现了瓶颈,可以先减少压测流量,比如,回退到上一次压测的 QPS,保证服务的稳定,再针对此服务或者组件进行扩容,然后再继续增加流量压测。

为了能够减少压力测试过程中,人力投入成本,可以开发一个流量监控的组件,在这个组件中,预先设定一些性能阈值。比如,容器的 CPU 使用率的阈值可以设定为 60%~70%;系统的平均响应时间的上限可以设定为 1 秒;系统慢请求的比例设置为 1% 等等。

当系统性能达到这个阈值之后,流量监控组件可以及时发现,并且通知压测流量下发组件减少压测流量,并且发送报警给到开发和运维的同学,开发和运维同学就迅速地排查性能瓶颈,在解决问题或者扩容之后再继续执行压测。

业界关于全链路压测平台的探索有很多,一些大厂比如阿里、京东、美团和微博都有了适合自身业务的全链路压测平台。在我看来,这些压测平台万变不离其宗,都无非是经过流量拷贝、流量染色隔离、打压、监控熔断等步骤,与本课程中介绍的核心思想都是相通的。因此,你在考虑自研适合自己项目的全链路压测平台时,也可以遵循这个成熟的套路。

课程小结

本节课,我带你了解了做压力测试常见的误区,以及自动化的全链路压测平台的搭建过程,这里你需要了解的重点是:

  1. 压力测试是一种发现系统性能隐患的重要手段,所以应该尽量使用正式的环境和数据;
  2. 对压测的流量需要增加标记,这样就可以通过 Mock 第三方依赖服务和影子库的方式来实现压测数据和正式数据的隔离;
  3. 压测时,应该实时地对系统性能指标做监控和告警,及时地对出现瓶颈的资源或者服务扩容,避免对正式环境产生影响。

这套全链路的压力测试系统对于我们来说有三方面的价值:

  • 其一,它可以帮助我们发现系统中可能出现的性能瓶颈,方便我们提前准备预案来应对;
  • 其次,它也可以为我们做容量评估,提供数据上的支撑;
  • 最后,我们也可以在压测的时候做预案演练,因为压测一般会安排在流量的低峰期进行,这样我们可以降级一些服务来验证预案效果,并且可以尽量减少对线上用户的影响。

所以,随着你的系统流量的快速增长,你也需要及时考虑搭建这么一套全链路压测平台,来保证你的系统的稳定性。

4.32 - CH33-维护-配置管理

相信在实际工作中,提及性能优化你会想到代码优化,但是实际上有些性能优化可能只需要调整一些配置参数就可以搞定了,为什么这么说呢?我给你举几个例子:

  • 你可以调整配置的超时时间,让请求快速失败,防止系统的雪崩,提升系统的可用性;
  • 你还可以调整 HTTP 客户端连接池的大小,来提升调用第三方 HTTP 服务的并行处理能力,从而提升系统的性能。

你可以认为,配置是管理你系统的工具,在你的垂直电商系统中,一定会有非常多的配置项,比如数据库的地址、请求 HTTP 服务的域名、本地内存最大缓存数量等等。

那么,你要如何对这些配置项做管理呢?管理的过程中要注意哪些事情呢?

如何对配置进行管理呢?

配置管理由来已久,比如在 Linux 系统中就提供了大量的配置项,你可以依据自身业务的实际情况,动态地对系统功能做调整。比如,

  • 你可以修改 dirty_writeback_centisecs 参数的数值,就可以调整 Page Cache 中脏数据刷新到磁盘上的频率;
  • 你也可以通过修改 tcp_max_syn_backlog 参数置的值,来调整未建立连接队列的长度。

修改这些参数既可以通过修改配置文件并且重启服务器来配置生效,也可以通过 sysctl 命令来动态地调整,让配置即时生效。

那么你在开发应用的时候,都有哪些管理配置的方式呢?主要有两种:

  • 一种是通过配置文件来管理;
  • 另一种是使用配置中心来管理。

以电商系统为例,你和你的团队在刚开始研发垂直电商系统时,为了加快产品的研发速度,大概率不会注意配置管理的问题,会自然而然地把配置项和代码写在一起。但是随着配置项越来越多,为了更好地对配置项进行管理,同时避免修改配置项后还要对代码做重新的编译,你选择把配置项独立成单独的文件(文件可以是 properties 格式、xml 格式或 yaml 格式)。不过,这些文件还是会和工程一起打包部署,只是更改配置后不需要对代码重新编译了。

随后,你很快发现了一个问题: 虽然把配置拆分了出来,但是由于配置还是和代码打包在一起,如果要更改一个配置,还是需要重新打包,这样无疑会增加打包的时间。于是,你考虑把配置写到单独的目录中,这样,修改配置就不需要再重新打包了(不过,由于配置并不能够实时地生效,所以想让配置生效,还是需要重启服务)。

我们一般使用的基础组件,比如 Tomcat,Nginx,都是采用上面这种配置文件的方式来管理配置项的,而在 Linux 系统中,我提到的 tcp_max_syn_backlog 就可以配置在 /etc/sysctl.conf 中。

这里,我需要强调一点,我们通常会把配置文件存储的目录,标准化为特定的目录。 比如,都配置成 /data/confs 目录,然后把配置项使用 Git 等代码仓库管理起来。这样,在增加新的机器时,在机器初始化脚本中,只需要创建这个目录,再从 Git 中拉取配置就可以了,是一个标准化的过程,这样可以避免在启动应用时忘记部署配置文件。

另外,如果你的服务是多机房部署的,那么不同机房的配置项中,有可能有相同的,或者有不同的。那么,你需要将相同的配置项放置在一个目录中给多个机房公用,再将不同的配置项,放置在以机房名为名称的目录中。在读取配置的时候应该优先读取机房的配置,再读取公共配置,这样可以减少配置文件中的配置项的数量。

我给你列了一个典型目录配置,如果你的系统也使用文件来管理配置,那么可以参考一下。

/data/confs/common/commerce // 电商业务的公共配置
/data/confs/commerce-zw     // 电商业务兆维机房配置
/data/confs/commerce-yz     // 电商业务亦庄机房配置
/data/confs/common/community // 社区业务的公共配置
/data/confs/community-zw     // 社区业务兆维机房配置
/data/confs/community-yz     // 社区业务亦庄机房配置

那么,这是不是配置管理的最终形态呢?当然不是,你不要忘了把配置放在文件中的方式还存在的问题(我上面也提到过了),那就是,我们必须将服务重启后,才能让配置生效。有没有一种方法可以在不重启应用的前提下,也能让配置生效呢?这就需要配置中心帮助我们实现了。

配置中心是如何实现的?

配置中心可以算是微服务架构中的一个标配组件了。业界也提供了多种开源方案供你选择,比较出名的有携程开源的 Apollo,百度开源的 Disconf,360 开源的 QConf,Spring Cloud 的组件 Spring Cloud Config 等等。

在我看来,Apollo 支持不同环境,不同集群的配置,有完善的管理功能,支持灰度发布、更改热发布等功能, 在所有配置中心中功能比较齐全,推荐你使用。

那么,配置中心的组件在实现上,有哪些关键的点呢?如果你想对配置中心组件有更强地把控力,想要自行研发一套符合自己业务场景的组件,又要如何入手呢?

配置信息如何存储

其实,配置中心和注册中心非常类似,其核心的功能就是对于 配置项的存储和读取。所以,在设计配置中心的服务端时,我们需要选择合适的存储组件,来存储大量的配置信息,这里可选择的组件有很多。

事实上,不同的开源配置中心也使用了不同的组件,比如 Disconf、Apollo 使用的是 MySQL;QConf 使用的是 ZooKeeper。我之前维护和使用的配置中心就会使用不同的存储组件,比如微博的配置中心使用 Redis 来存储信息,而美图的则使用 Etcd。

而无论使用哪一种存储组件,你所要做的就是规范配置项在其中的存储结构。比如,我之前使用的配置中心用 Etcd 作为存储组件,支持存储全局配置、机房配置和节点配置。其中,节点配置优先级高于机房配置,机房配置优先级高于全局配置。也就是说,我们会优先读取节点的配置,如果节点配置不存在,再读取机房配置,最后读取全局配置。它们的存储路径如下:

/confs/global/{env}/{project}/{service}/{version}/{module}/{key} // 全局配置
/confs/regions/{env}/{project}/{service}/{version}/{region}/{module}/{key}   // 机房配置 
/confs/nodes/{env}/{project}/{service}/{version}/{region}/{node}/{module}/{key}     // 节点配置

变更推送如何实现

配置信息存储之后,我们需要考虑如何将配置的变更推送给服务端,这样就可以实现配置的动态变更,不需要重启服务器就能让配置生效了。而我们一般会有两种思路来实现变更推送:

  • 一种是轮询查询的方式;
  • 一种是长连推送的方式。

轮询查询很简单,就是应用程序向配置中心客户端注册一个监听器,配置中心的客户端,定期地(比如 1 分钟)查询所需要的配置是否有变化,如果有变化则通知触发监听器,让应用程序得到变更通知。

这里有一个需要注意的点, 如果有很多应用服务器都去轮询拉取配置,由于返回的配置项可能会很大,那么配置中心服务的带宽就会成为瓶颈。为了解决这个问题,我们会给配置中心的每一个配置项,多存储一个根据配置项计算出来的 MD5 值。

配置项一旦变化,这个 MD5 值也会随之改变。配置中心客户端在获取到配置的同时,也会获取到配置的 MD5 值,并且存储起来。那么在轮询查询的时候,需要先确认存储的 MD5 值,和配置中心的 MD5 是不是一致的。如果不一致,这就说明配置中心中,存储的配置项有变化,然后才会从配置中心拉取最新的配置。

由于配置中心里存储的配置项变化的几率不大,所以使用这种方式后,每次轮询请求就只是返回一个 MD5 值,可以大大地减少配置中心服务器的带宽。

NAME

另一种长连的方式,则是在配置中心服务端保存每个连接关注的配置项列表。这样,当配置中心感知到配置变化后,就可以通过这个连接,把变更的配置推送给客户端。这种方式需要保持长连,也需要保存连接和配置的对应关系,实现上要比轮询的方式复杂一些,但是相比轮询方式来说,能够更加实时地获取配置变更的消息。

而在我看来,配置服务中存储的配置变更频率不高,所以对于实时性要求不高,但是期望实现上能够足够简单, 所以如果选择自研配置中心的话,可以考虑使用轮询的方式。

如何保证配置中心高可用

除了变更通知以外,在配置中心实现中,另外一个比较关键的点在于如何保证它的可用性,因为对于配置中心来说,它的可用性的重要程度要远远大于性能。这是因为我们一般会在服务器启动时,从配置中心中获取配置,如果配置获取的性能不高,那么外在的表现也只是应用启动时间慢了,对于业务的影响不大;但是,如果获取不到配置,很可能会导致启动失败。

比如,我们把数据库的地址存储在了配置中心里,如果配置中心宕机导致我们无法获取数据库的地址,那么自然应用程序就会启动失败。 因此,我们的诉求是让配置中心「旁路化」。 也就是说,即使配置中心宕机,或者配置中心依赖的存储宕机,我们仍然能够保证应用程序是可以启动的。那么这是如何实现的呢?

我们一般会在配置中心的客户端上,增加两级缓存:第一级缓存是内存的缓存;另外一级缓存是文件的缓存。

配置中心客户端在获取到配置信息后,会同时把配置信息同步地写入到内存缓存,并且异步地写入到文件缓存中。内存缓存的作用是降低客户端和配置中心的交互频率,提升配置获取的性能;而文件的缓存的作用就是灾备,当应用程序重启时,一旦配置中心发生故障,那么应用程序就会优先使用文件中的配置,这样虽然无法得到配置的变更消息(因为配置中心已经宕机了),但是应用程序还是可以启动起来的,算是一种降级的方案。

课程小结

以上就是本节课的全部内容了。在这节课中,我带你了解了系统开发的过程中,我们是如何管理大量的配置项的,你需要了解的重点是:

  1. 配置存储是分级的,有公共配置,有个性的配置,一般个性配置会覆盖公共配置,这样可以减少存储配置项的数量;
  2. 配置中心可以提供配置变更通知的功能,可以实现配置的热更新;
  3. 配置中心关注的性能指标中,可用性的优先级是高于性能的,一般我们会要求配置中心的可用性达到 99.999%,甚至会是 99.9999%。

这里你需要注意的是,并不是所有的配置项都需要使用配置中心来存储,如果你的项目还是使用文件方式来管理配置,那么你只需要,将类似超时时间等,需要动态调整的配置,迁移到配置中心就可以了。对于像是数据库地址,依赖第三方请求的地址,这些基本不会发生变化的配置项,可以依然使用文件的方式来管理,这样可以大大地减少配置迁移的成本。

4.33 - CH34-维护-降级熔断

到目前为止,你的电商系统已经搭建了完善的服务端和客户端监控系统,并且完成了全链路压测。现在呢,你们已经发现和解决了垂直电商系统中很多的性能问题和隐患。但是千算万算,还是出现了纰漏。

本来,你们对于应对「双十一」的考验信心满满,但因为欠缺了一些面对巨大流量的经验,在促销过程中出现了几次短暂的服务不可用,这给部分用户造成了不好的使用体验。事后,你们进行了细致的复盘,追查出现故障的根本原因,你发现,原因主要可以归结为两大类。

  • 第一类原因是由于依赖的资源或者服务不可用,最终导致整体服务宕机。举例来说,在你的电商系统中就可能由于数据库访问缓慢,导致整体服务不可用。
  • 另一类原因是你们乐观地预估了可能到来的流量,当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。

那么,你要如何避免再次出现这两类问题呢?我建议你采取降级、熔断以及限流的方案。限流是解决第二类问题的主要思路(下一节课,我会着重讲解)。今天这节课,我主要讲一下解决第一类问题的思路:降级和熔断。

不过在此之前,我先带你了解一下这个问题为何存在,因为你只有弄清楚出现故障的原理,才能更好地理解熔断降级带来的好处。

雪崩是如何发生的

局部故障最终导致全局故障,这种情况有一个专业的名词儿,叫做「雪崩」。那么,为什么会发生雪崩呢?我们知道,系统在运行的时候是需要消耗一些资源的,包括 CPU、内存等系统资源,也包括执行业务逻辑的时候,需要的线程资源。

举个例子,一般在业务执行的容器内,都会定义一些线程池来分配执行任务的线程,比如在 Tomcat 这种 Web 容器的内部,定义了线程池来处理 HTTP 请求;RPC 框架也给 RPC 服务端初始化了线程池来处理 RPC 请求。

这些线程池中的线程资源是有限的,如果这些线程资源被耗尽,那么服务自然也就无法处理新的请求,服务提供方也就宕机了。比如,你的垂直电商系统有四个服务 A、B、C、D,A 调用 B,B 调用 C 和 D。其中,A、B、D 服务是系统的核心服务(像是电商系统中的订单服务、支付服务等等),C 是非核心服务(像反垃圾服务、审核服务)。

所以,一旦作为入口的 A 流量增加,你可能会考虑把 A、B 和 D 服务扩容,忽略 C。那么 C 就有可能因为无法承担这么大的流量,导致请求处理缓慢,进一步会让 B 在调用 C 的时候,B 中的请求被阻塞,等待 C 返回响应结果。这样一来,B 服务中被占用的线程资源就不能释放。

久而久之,B 就会因为线程资源被占满,无法处理后续的请求。那么从 A 发往 B 的请求,就会被放入 B 服务线程池的队列中,然后 A 调用 B 响应时间变长,进而拖垮 A 服务。你看,仅仅因为非核心服务 C 的响应时间变长,就可以导致整体服务宕机, 这就是我们经常遇到的一种服务雪崩情况。

NAME

那么我们要如何避免出现上面这种情况呢?从我刚刚的介绍中你可以看到,因为服务调用方等待服务提供方的响应时间过长,它的资源被耗尽,才引发了级联反应,发生雪崩。

所以在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机,而是最怕它响应缓慢,因为,某一个服务或者组件宕机也许只会影响系统的部分功能,但它响应一慢,就会出现雪崩拖垮整个系统。

而我们在部门内部讨论方案的时候,会格外注意这个问题,解决的思路就是在检测到某一个服务的响应时间出现异常时,切断调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源。 这个思路也就是我们经常提到的降级和熔断机制。

那么降级和熔断分别是怎么做的呢?它们之间有什么相同点和不同点呢?你在自己的项目中要如何实现熔断降级呢?

熔断机制是如何做的

首先,我们来看看熔断机制的实现方式。这个机制参考的是电路中保险丝的保护机制,当电路超负荷运转的时候,保险丝会断开电路,保证整体电路不受损害。而服务治理中的熔断机制指的是在发起服务调用的时候,如果返回错误或者超时的次数超过一定阈值,则后续的请求不再发向远程服务而是暂时返回错误。

这种实现方式在云计算领域又称为 断路器模式 ,在这种模式下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机中会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。这三种状态之间切换的过程是下面这个样子。

  • 当调用失败的次数累积到一定的阈值时,熔断状态从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。
  • 当熔断处于打开状态时,我们会启动一个超时计时器,当计时器超时后,状态切换到半打开态。你也可以通过设置一个定时器,定期地探测服务是否恢复。
  • 在熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态。
NAME

其实,不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制。在我的团队自己封装的 Redis 客户端中,就实现了一套简单的熔断机制。首先,在系统初始化的时候,我们定义了一个定时器,当熔断器处于 Open 状态时,定期地检测 Redis 组件是否可用:

new Timer("RedisPort-Recover", true).scheduleAtFixedRate(new TimerTask() {
    @Override
    public void run() {
        if (breaker.isOpen()) {
            Jedis jedis = null;
            try {
                jedis = connPool.getResource();
                jedis.ping(); // 验证 redis 是否可用
                successCount.set(0); // 重置连续成功的计数
                breaker.setHalfOpen(); // 设置为半打开态
            } catch (Exception ignored) {
            } finally {
                if (jedis != null) {
                    jedis.close();
                }
            }
        }
    }
}, 0, recoverInterval); // 初始化定时器定期检测 redis 是否可用

在通过 Redis 客户端操作 Redis 中的数据时,我们会在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换,具体的示例代码像下面这样:


if (breaker.isOpen()) { 
    return null;  // 断路器打开则直接返回空值
}
K value = null;
Jedis jedis = null;
try {
     jedis = connPool.getResource();
     value = callback.call(jedis);
     if(breaker.isHalfOpen()) { // 如果是半打开状态
          if(successCount.incrementAndGet() >= SUCCESS_THRESHOLD) {// 成功次数超过阈值
                failCount.set(0);  // 清空失败数
                breaker.setClose(); // 设置为关闭态
          }
     }
     return value;
} catch (JedisException je) {
     if(breaker.isClose()){  // 如果是关闭态
         if(failCount.incrementAndGet() >= FAILS_THRESHOLD){ // 失败次数超过阈值
            breaker.setOpen();  // 设置为打开态
         }
     } else if(breaker.isHalfOpen()) {  // 如果是半打开态
         breaker.setOpen();    // 直接设置为打开态
     }
     throw  je;
} finally {
     if (jedis != null) {
           jedis.close();
     }
}

这样,当某一个 Redis 节点出现问题,Redis 客户端中的熔断器就会实时监测到,并且不再请求有问题的 Redis 节点,避免单个节点的故障导致整体系统的雪崩。

降级机制要如何做

除了熔断之外,我们在听业内分享的时候,听到最多的服务容错方式就是降级,那么降级又是怎么做的呢?它和熔断有什么关系呢?

其实在我看来,相比熔断来说,降级是一个更大的概念。因为它是站在整体系统负载的角度上,放弃部分非核心功能或者服务,保证整体的可用性的方法,是一种有损的系统容错方式。这样看来,熔断也是降级的一种,除此之外还有限流降级、开关降级等等(限流降级我会在下一节课中提到,这节课主要讲一下 开关降级)。

开关降级指的是在代码中预先埋设一些 开关,用来控制服务调用的返回值。比方说,开关关闭的时候正常调用远程服务,开关打开时则执行降级的策略。这些开关的值可以存储在配置中心中,当系统出现问题需要降级时,只需要通过配置中心动态更改开关的值,就可以实现不重启服务快速地降级远程服务了。

还是以电商系统为例,你的电商系统在商品详情页面除了展示商品数据以外,还需要展示评论的数据,但是主体还是商品数据,在必要时可以降级评论数据。所以,你可以定义这个开关为 degrade.comment,写入到配置中心中,具体的代码也比较简单,就像下面这样:

boolean switcherValue = getFromConfigCenter("degrade.comment"); // 从配置中心获取开关的值
if (!switcherValue) {
  List<Comment> comments = getCommentList(); // 开关关闭则获取评论数据
} else {
  List<Comment> comments = new ArrayList(); // 开关打开,则直接返回空评论数据
}

当然了,我们在 设计开关降级预案的时候,首先要区分哪些是核心服务,哪些是非核心服务因为我们只能针对非核心服务来做降级处理 ,然后就可以针对具体的业务,制定不同的降级策略了。我给你列举一些常见场景下的降级策略,你在实际的工作中可以参考借鉴。

  • 针对读取数据的场景,我们一般采用的策略是直接返回降级数据。

    比如,如果数据库的压力比较大,我们在降级的时候,可以考虑只读取缓存的数据,而不再读取数据库中的数据;如果非核心接口出现问题,可以直接返回服务繁忙或者返回固定的降级数据。

  • 对于一些轮询查询数据的场景,比如每隔 30 秒轮询获取未读数,可以降低获取数据的频率(将获取频率下降到 10 分钟一次)。

  • 而对于写数据的场景,一般会考虑把同步写转换成异步写,这样可以牺牲一些数据一致性和实效性来保证系统的可用性。

我想强调的是,只有经过演练的开关才是有用的开关, 有些同学在给系统加了开关之后并不测试,结果出了问题真要使用的时候,却发现开关并不生效。因此,你在为系统增加降级开关时,一定要在流量低峰期的时候做验证演练,也可以在不定期的压力测试过程中演练,保证开关的可用性。

课程小结

以上就是本节课的全部内容了。本节课我带你了解了雪崩产生的原因,服务熔断的实现方式以及服务降级的策略,这里你需要了解的重点是:

  1. 在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务。
  2. 服务熔断的实现是一个有限状态机,关键是三种状态之间的转换过程。
  3. 开关降级的实现策略主要有返回降级数据、降频和异步三种方案。

其实,开关不仅仅应该在你的降级策略中使用,在我的项目中,只要上线新的功能必然要加开关控制业务逻辑是运行新的功能还是运行旧的功能。这样,一旦新的功能上线后,出现未知的问题(比如性能问题),那么可以通过切换开关的方式来实现快速地回滚,减少问题的持续时间。

总之,熔断和降级是保证系统稳定性和可用性的重要手段,在你访问第三方服务或者资源的时候都需要考虑增加降级开关或者熔断机制,保证资源或者服务出现问题时,不会对整体系统产生灾难性的影响。

4.34 - CH35-维护-流量控制

上一节课里,我带你了解了微服务架构中常见的 两种有损的服务保护策略:熔断和降级。它们都是通过暂时关闭某些非核心服务或者组件从而保护核心系统的可用性。但是,并不是所有的场景下都可以使用熔断降级的策略,比如,电商系统在双十一、618 大促的场景。

这种场景下,系统的峰值流量会超过了预估的峰值,对于核心服务也产生了比较大的影响,而你总不能把核心服务整体降级吧?那么在这个时候要如何保证服务的稳定性呢?你认为可以使用限流的方案。而提到限流,我相信你多多少少在以下几个地方出错过:

  • 限流算法选择不当,导致限流效果不好;
  • 开启了限流却发现整体性能有损耗;
  • 只实现了单机的限流却没有实现整体系统的限流。

说白了,你之所以出现这些问题还是对限流的算法以及实际应用不熟练,而本节课,我将带你了解这些内容,期望你能将这些经验应用到实际项目中,从而提升整体系统的鲁棒性。

究竟什么是限流

限流指的是通过限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则只能通过拒绝服务的方式保证整体系统的可用性 。限流策略一般部署在服务的入口层,比如 API 网关中,这样可以对系统整体流量做塑形。而在微服务架构中,你也可以在 RPC 客户端中引入限流的策略,来保证单个服务不会被过大的流量压垮。

其实,无论在实际工作生活中还是在之前学习过的知识中,你都可能对限流策略有过应用,我给你举几个例子。

比如,到了十一黄金周的时候你想去九寨沟游玩,结果到了九寨沟才发现景区有了临时的通知,每天仅仅售卖 10 万张门票,而当天没有抢到门票的游客就只能第二天起早继续来抢了。这就是一种常见的限流策略,也就是对一段时间内(在这里是一天)流量做整体的控制,它可以避免出现游客过多导致的景区环境受到影响的情况,也能保证游客的安全。而且,如果你挤过地铁,就更能感同身受了。北京早高峰的地铁都会限流,想法很直接,就是控制进入地铁的人数,保证地铁不会被挤爆,也可以尽量保障人们的安全。

再比如,在 TCP 协议中有一个滑动窗口的概念,可以实现对网络传输流量的控制。你可以想象一下,如果没有流量控制,当流量接收方处理速度变慢而发送方还是继续以之前的速率发送数据,那么必然会导致流量拥塞。而 TCP 的滑动窗口实际上可以理解为接收方所能提供的缓冲区的大小。

在接收方回复发送方的 ACK 消息中,会带上这个窗口的大小。这样,发送方就可以通过这个滑动窗口的大小决定发送数据的速率了。如果接收方处理了一些缓冲区的数据,那么这个滑动窗口就会变大,发送方发送数据的速率就会提升;反之,如果接收方接收了一些数据还没有来得及处理,那么这个滑动窗口就会减小,发送方发送数据的速率就会减慢。

NAME

而无论是在一体化架构还是微服务化架构中,我们也可以在多个维度上对到达系统的流量做控制,比如:

  • 你可以对系统每分钟处理多少请求做限制;
  • 可以针对单个接口设置每分钟请求流量的限制;
  • 可以限制单个 IP、用户 ID 或者设备 ID 在一段时间内发送请求的数量;
  • 对于服务于多个第三方应用的开放平台来说,每一个第三方应用对于平台方来说都有一个唯一的 appkey 来标识,那么你也可以限制单个 appkey 的访问接口的速率。

而实现上述限制速率的方式是基于一些限流算法的,那么常见的限流的算法有哪些呢?你在实现限流的时候都有哪些方式呢?

你应该知道的限流算法

固定窗口与滑动窗口的算法

我们知道,限流的目的是限制一段时间内发向系统的总体请求量,比如,限制一分钟之内系统只能承接 1 万次请求,那么最暴力的一种方式就是记录这一分钟之内访问系统的请求量有多少,如果超过了 1 万次的限制,那么就触发限流的策略返回请求失败的错误。如果这一分钟的请求量没有达到限制,那么在下一分钟到来的时候先重置请求量的计数,再统计这一分钟的请求量是否超过限制

这种算法叫做 固定窗口算法 ,在实现它的时候,首先要启动一个定时器定期重置计数,比如你需要限制每秒钟访问次数,那么简单的实现代码是这样的:

private AtomicInteger counter;
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
timer.scheduleAtFixedRate(new Runnable(){
    @Override
    public void run() {
        counter.set(0);
    }
}, 0, 1, TimeUnit.SECONDS);

而限流的逻辑就非常简单了,只需要比较计数值是否大于阈值就可以了:

public boolena isRateLimit() {
  return counter.incrementAndGet() >= allowedLimit;
}

这种算法虽然实现非常简单,但是却有一个很大的缺陷 :无法限制短时间之内的集中流量。假如我们需要限制每秒钟只能处理 10 次请求,如果前一秒钟产生了 10 次请求,这 10 次请求全部集中在最后的 10 毫秒中,而下一秒钟的前 10 毫秒也产生了 10 次请求,那么在这 20 毫秒中就产生了 20 次请求,超过了限流的阈值。但是因为这 20 次请求分布在两个时间窗口内,所以没有触发限流,这就造成了限流的策略并没有生效。

NAME

为了解决这个缺陷,就有了基于滑动窗口的算法。 这个算法的原理是将时间的窗口划分为多个小窗口,每个小窗口中都有单独的请求计数。比如下面这张图,我们将 1s 的时间窗口划分为 5 份,每一份就是 200ms;那么当在 1s 和 1.2s 之间来了一次新的请求时,我们就需要统计之前的一秒钟内的请求量,也就是 0.2s~1.2s 这个区间的总请求量,如果请求量超过了限流阈值那么就执行限流策略。

NAME

滑动窗口的算法解决了 临界时间点上突发流量无法控制的问题,但是却因为要存储每个小的时间窗口内的计数,所以空间复杂度有所增加。

虽然滑动窗口算法解决了窗口边界的大流量的问题,但是它和固定窗口算法一样,还是无法限制短时间之内的集中流量,也就是说无法控制流量让它们更加平滑。 因此,在实际的项目中,我很少使用基于时间窗口的限流算法,而是使用其他限流的算法:一种算法叫做漏桶算法,一种叫做令牌筒算法。

漏桶算法与令牌筒算法

漏桶算法的原理很简单,它就像在流量产生端和接收端之间增加一个漏桶,流量会进入和暂存到漏桶里面,而漏桶的出口处会按照一个固定的速率将流量漏出到接收端(也就是服务接口)。

如果流入的流量在某一段时间内大增,超过了漏桶的承受极限,那么多余的流量就会触发限流策略,被拒绝服务。

经过了漏桶算法之后,随机产生的流量就会被整形成为比较平滑的流量到达服务端,从而避免了突发的大流量对于服务接口的影响。 这很像倚天屠龙记里,九阳真经的口诀:他强由他强,清风拂山岗,他横由他横,明月照大江 。 也就是说,无论流入的流量有多么强横,多么不规则,经过漏桶处理之后,流出的流量都会变得比较平滑。

而在实现时,我们一般会使用消息队列作为漏桶的实现,流量首先被放入到消息队列中排队,由固定的几个队列处理程序来消费流量,如果消息队列中的流量溢出,那么后续的流量就会被拒绝。这个算法的思想是不是与消息队列削峰填谷的作用相似呢?

NAME

另一种令牌桶算法的基本算法是这样的:

  • 如果我们需要在一秒内限制访问次数为 N 次,那么就每隔 1/N 的时间,往桶内放入一个令牌;
  • 在处理请求之前先要从桶中获得一个令牌,如果桶中已经没有了令牌,那么就需要等待新的令牌或者直接拒绝服务;
  • 桶中的令牌总数也要有一个限制,如果超过了限制就不能向桶中再增加新的令牌了。这样可以限制令牌的总数,一定程度上可以避免瞬时流量高峰的问题。
NAME

如果要从这两种算法中做选择,我更倾向于使用令牌桶算法,原因是漏桶算法在面对突发流量的时候,采用的解决方式是缓存在漏桶中, 这样流量的响应时间就会增长,这就与互联网业务低延迟的要求不符;而令牌桶算法可以在令牌中暂存一定量的令牌,能够应对一定的突发流量,所以一般我会使用令牌桶算法来实现限流方案,而 Guava 中的限流方案就是使用令牌桶算法来实现的。

你可以看到,使用令牌桶算法就需要存储令牌的数量,如果是单机上实现限流的话,可以在进程中使用一个变量来存储;但是如果在分布式环境下,不同的机器之间无法共享进程中的变量,我们就一般会使用 Redis 来存储这个令牌的数量。这样的话,每次请求的时候都需要请求一次 Redis 来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。 因此,一个折中的思路是: 我们可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数。

课程小结

以上就是本节课的全部内容了。本节课我带你了解了限流的定义和作用,以及常见的几种限流算法,你需要了解的重点是:

  1. 限流是一种常见的服务保护策略,你可以在整体服务、单个服务、单个接口、单个 IP 或者单个用户等多个维度进行流量的控制;
  2. 基于时间窗口维度的算法有固定窗口算法和滑动窗口算法,两者虽然能一定程度上实现限流的目的,但是都无法让流量变得更平滑;
  3. 令牌桶算法和漏桶算法则能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际项目中应用更多。

限流策略是微服务治理中的标配策略,只是你很难在实际中确认限流的阈值是多少,设置的小了容易误伤正常的请求,设置的大了则达不到限流的目的。所以,一般在实际项目中,我们会把阈值放置在配置中心中方便动态调整;同时,我们可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力,然后再依据这个压测出来的值设置合适的阈值。

4.35 - CH37-实战-计数系统-1

从今天开始,我们正式进入最后的实战篇。在之前的课程中,我分别从数据库、缓存、消息队列和分布式服务化的角度,带你了解了面对高并发的时候要如何保证系统的高性能、高可用和高可扩展。课程中虽然有大量的例子辅助你理解理论知识,但是没有一个完整的实例帮你把知识串起来。

所以,为了将我们提及的知识落地,在实战篇中,我会以微博为背景,用两个完整的案例带你从实践的角度应对高并发大流量的冲击,期望给你一个更加具体的感性认识,为你在实现类似系统的时候提供一些思路。今天我要讲的第一个案例是如何设计一个支持高并发大存储量的计数系统。

来看这样一个场景: 在地铁上,你也许会经常刷微博、点赞热搜,如果有抽奖活动,再转发一波,而这些与微博息息相关的数据,其实就是微博场景下的 计数数据,细说起来,它主要有几类:

  1. 微博的评论数、点赞数、转发数、浏览数、表态数等等;
  2. 用户的粉丝数、关注数、发布微博数、私信数等等。

微博维度的计数代表了这条微博受欢迎的程度,用户维度的数据(尤其是粉丝数),代表了这个用户的影响力,因此大家会普遍看重这些计数信息。并且在很多场景下,我们都需要查询计数数据(比如首页信息流页面、个人主页面),计数数据访问量巨大,所以需要设计计数系统维护它。

但在设计计数系统时,不少人会出现性能不高、存储成本很大的问题,比如,把计数与微博数据存储在一起,这样每次更新计数的时候都需要锁住这一行记录,降低了写入的并发。在我看来,之所以出现这些问题,还是因为你对计数系统的设计和优化不甚了解,所以要想解决痛点,你有必要形成完备的设计方案。

计数在业务上的特点

首先,你要了解这些计数在业务上的特点是什么,这样才能针对特点设计出合理的方案。在我看来,主要有这样几个特点。

  • 数据量巨大

    据我所知,微博系统中微博条目的数量早已经超过了千亿级别,仅仅计算微博的转发、评论、点赞、浏览等核心计数,其数据量级就已经在几千亿的级别。更何况微博条目的数量还在不断高速地增长,并且随着微博业务越来越复杂,微博维度的计数种类也可能会持续扩展(比如说增加了表态数),因此,仅仅是微博维度上的计数量级就已经过了万亿级别。除此之外,微博的用户量级已经超过了 10 亿,用户维度的计数量级相比微博维度来说虽然相差很大,但是也达到了百亿级别。那么如何存储这些过万亿级别的数字,对我们来说就是一大挑战。

  • 访问量大,对于性能的要求高

    微博的日活用户超过 2 亿,月活用户接近 5 亿,核心服务(比如首页信息流)访问量级到达每秒几十万次,计数系统的访问量级也超过了每秒百万级别,而且在性能方面,它要求要毫秒级别返回结果。

  • 最后,对于可用性、数字的准确性要求高

    一般来讲,用户对于计数数字是非常敏感的,比如你直播了好几个月,才涨了 1000 个粉,突然有一天粉丝数少了几百个,那么你是不是会琢磨哪里出现问题,或者打电话投诉直播平台?

那么,面临着高并发、大数据量、数据强一致要求的挑战,微博的计数系统是如何设计和演进的呢?你又能从中借鉴什么经验呢?

支撑高并发的计数系统要如何设计

刚开始设计计数系统的时候,微博的流量还没有现在这么夸张,我们本着 KISS(Keep It Simple and Stupid)原则,尽量将系统设计的简单易维护,所以,我们使用 MySQL 存储计数的数据,因为它是我们最熟悉的,团队在运维上经验也会比较丰富。举个具体的例子。

假如要存储微博维度(微博的计数,转发数、赞数等等)的数据,你可以这么设计表结构:以微博 ID 为主键,转发数、评论数、点赞数和浏览数分别为单独一列,这样在获取计数时用一个 SQL 语句就搞定了。

select repost_count, comment_count, praise_count, view_count from t_weibo_count where weibo_id = ?

在数据量级和访问量级都不大的情况下,这种方式最简单,所以如果你的系统量级不大,你可以直接采用这种方式来实现。

后来,随着微博的不断壮大,之前的计数系统面临了很多的问题和挑战。

比如微博用户量和发布的微博量增加迅猛,计数存储数据量级也飞速增长,而 MySQL 数据库单表的存储量级达到几千万的时候,性能上就会有损耗。所以我们考虑使用分库分表的方式分散数据量,提升读取计数的性能。

我们用 「weibo_id」作为分区键,在选择分库分表的方式时,考虑了下面两种:

  • 一种方式是选择一种哈希算法对 weibo_id 计算哈希值,然后依据这个哈希值计算出需要存储到哪一个库哪一张表中,具体的方式你可以回顾一下第 9 讲数据库分库分表的内容;
  • 另一种方式是按照 weibo_id 生成的时间来做分库分表,我们在第 10 讲谈到发号器的时候曾经提到,ID 的生成最好带有业务意义的字段,比如生成 ID 的时间戳。所以在分库分表的时候,可以先依据发号器的算法反解出时间戳,然后按照时间戳来做分库分表,比如,一天一张表或者一个月一张表等等。

因为越是最近发布的微博,计数数据的访问量就越大,所以虽然我考虑了两种方案,但是按照时间来分库分表会造成数据访问的不均匀,最后用了哈希的方式来做分库分表。

NAME

与此同时,计数的访问量级也有质的飞越。在微博最初的版本中,首页信息流里面是不展示计数数据的,那么使用 MySQL 也可以承受当时读取计数的访问量。但是后来在首页信息流中也要展示转发、评论和点赞等计数数据了。而信息流的访问量巨大,仅仅靠数据库已经完全不能承担如此高的并发量了。于是我们 考虑使用 Redis 来加速读请求 ,通过部署多个从节点来提升可用性和性能,并且通过 Hash 的方式对数据做分片,也基本上可以保证计数的读取性能。然而,这种数据库 + 缓存的方式有一个弊端:无法保证数据的一致性 ,比如,如果数据库写入成功而缓存更新失败,就会导致数据的不一致,影响计数的准确性。所以,我们完全抛弃了 MySQL,全面使用 Redis 来作为计数的存储组件。

NAME

除了考虑计数的读取性能之外,由于热门微博的计数变化频率相当快,也需要考虑如何提升计数的写入性能。比如,每次在转发一条微博的时候,都需要增加这条微博的转发数,那么如果明星发布结婚、离婚的微博,瞬时就可能会产生几万甚至几十万的转发。如果是你的话,要如何降低写压力呢?

你可能已经想到用消息队列来削峰填谷了,也就是说,我们在转发微博的时候向消息队列写入一条消息,然后在消息处理程序中给这条微博的转发计数加 1。 这里需要注意的一点, 我们可以通过批量处理消息的方式进一步减小 Redis 的写压力,比如像下面这样连续更改三次转发数(我用 SQL 来表示来方便你理解):

UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1; 
UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1;  
UPDATE  t_weibo_count SET repost_count = repost_count +1 WHERE weibo_id = 1; 

这个时候,你可以把它们合并成一次更新:

UPDATE t_weibo_count SET repost_count = repost_count + 3 WHERE weibo_id = 1; 

如何降低计数系统的存储成本

讲到这里,我其实已经告诉你一个支撑高并发查询请求的计数系统是如何实现的了。但是在微博的场景下,计数的量级是万亿的级别,这也给我们提了更高的要求,就是如何在有限的存储成本下实现对于全量计数数据的存取。

你知道,Redis 是使用内存来存储信息,相比于使用磁盘存储数据的 MySQL 来说,存储的成本不可同日而语,比如一台服务器磁盘可以挂载到 2 个 T,但是内存可能只有 128G,这样磁盘的存储空间就是内存的 16 倍。而 Redis 基于通用性的考虑,对于内存的使用比较粗放,存在大量的指针以及额外数据结构的开销,如果要存储一个 KV 类型的计数信息,Key 是 8 字节 Long 类型的 weibo_id,Value 是 4 字节 int 类型的转发数,存储在 Redis 中之后会占用超过 70 个字节的空间,空间的浪费是巨大的。 如果你面临这个问题,要如何优化呢?

我建议你先对原生 Redis 做一些改造,采用新的数据结构和数据类型来存储计数数据。我在改造时,主要涉及了两点:

  • 一是原生的 Redis 在存储 Key 时是按照字符串类型来存储的,比如一个 8 字节的 Long 类型的数据,需要 8(sdshdr 数据结构长度)+ 19(8 字节数字的长度)+1(\0)= 28 个字节,如果我们使用 Long 类型来存储就只需要 8 个字节,会节省 20 个字节的空间;
  • 二是去除了原生 Redis 中多余的指针,如果要存储一个 KV 信息就只需要 8(weibo_id)+4(转发数)=12 个字节,相比之前有很大的改进。

同时,我们也会使用一个大的数组来存储计数信息,存储的位置是基于 weibo_id 的哈希值来计算出来的,具体的算法像下面展示的这样:

插入时:
h1 = hash1(weibo_id) // 根据微博 ID 计算 Hash
h2 = hash2(weibo_id) // 根据微博 ID 计算另一个 Hash,用以解决前一个 Hash 算法带来的冲突
for s in 0,1000
   pos = (h1 + h2*s) % tsize // 如果发生冲突,就多算几次 Hash2
     if(isempty(pos) || isdelete(pos))
         t[ pos ] = item  // 写入数组
查询时:
for s in 0,1000
   pos = (h1 + h2*s) % tsize  // 依照插入数据时候的逻辑,计算出存储在数组中的位置
      if(!isempty(pos) && t[pos]==weibo_id)
         return t[pos]
return 0 
删除时:
insert(FFFF) // 插入一个特殊的标

笔者没太看明白上面这个伪代码的含义是什么,计算几次 hash 不是固定次数吗?查询里面也是循环 1000,找到就返回,如果次数不固定,hash 冲突不就拿到错误的了?

在对原生的 Redis 做了改造之后,你还需要进一步考虑如何节省内存的使用。比如,微博的计数有转发数、评论数、浏览数、点赞数等等,如果每一个计数都需要存储 weibo_id,那么总共就需要 8(weibo_id)x4(4 个微博 ID)+ 4(转发数) + 4(评论数) + 4(点赞数) + 4(浏览数)= 48 字节。但是我们可以把相同微博 ID 的计数存储在一起,这样就只需要记录一个微博 ID,省掉了多余的三个微博 ID 的存储开销,存储空间就进一步减少了。

不过,即使经过上面的优化,由于计数的量级实在是太过巨大,并且还在以极快的速度增长,所以如果我们以全内存的方式来存储计数信息,就需要使用非常多的机器来支撑。

然而微博计数的数据具有明显的热点属性:越是最近的微博越是会被访问到,时间上久远的微博被访问的几率很小 。所以为了尽量减少服务器的使用,我们考虑给计数服务增加 SSD 磁盘,然后将时间上比较久远的数据 dump 到磁盘上,内存中只保留最近的数据。当我们要读取冷数据的时候,使用单独的 I/O 线程异步地将冷数据从 SSD 磁盘中加载到一块儿单独的 Cold Cache(冷缓存) 中。

NAME

在经过了上面这些优化之后,我们的计数服务就可以支撑高并发大数据量的考验,无论是在性能上、成本上和可用性上都能够达到业务的需求了。

总的来说,我用微博设计计数系统的例子,并不是仅仅告诉你计数系统是如何做的,而是想告诉你在做系统设计的时候需要了解自己系统目前的痛点是什么,然后再针对痛点来做细致的优化。比如,微博计数系统的痛点是存储的成本,那么我们后期做的事情很多都是围绕着如何使用有限的服务器存储全量的计数数据,即使是对开源组件(Redis)做深度的定制会带来很大的运维成本,也只能被认为是为了实现计数系统而必须要做的权衡。

课程小结

以上就是本节课的全部内容了。本节课我以微博为例带你了解了如何实现一套存储千亿甚至万亿数据的高并发计数系统,这里你需要了解的重点如下:

  1. 数据库 + 缓存的方案是计数系统的初级阶段,完全可以支撑中小访问量和存储量的存储服务。如果你的项目还处在初级阶段,量级还不是很大,那么你一开始可以考虑使用这种方案。
  2. 通过对原生 Redis 组件的改造,我们可以极大地减小存储数据的内存开销。
  3. 使用 SSD+ 内存的方案可以最终解决存储计数数据的成本问题。这个方式适用于冷热数据明显的场景,你在使用时需要考虑如何将内存中的数据做换入换出。

其实,随着互联网技术的发展,已经有越来越多的业务场景需要使用上百 G 甚至几百 G 的内存资源来存储业务数据,但是对于性能或者延迟并没有那么高的要求,如果全部使用内存来存储无疑会带来极大的成本浪费。因此,在业界有一些开源组件也在支持使用 SSD 替代内存存储冷数据,比如 Pika,SSDB,这两个开源组件,我建议你可以了解一下它们的实现原理,这样可以在项目中需要的时候使用。而且,在微博的计数服务中也采用了类似的思路,如果你的业务中也需要使用大量的内存,存储热点比较明显的数据,不妨也可以考虑使用类似的思路。

4.36 - CH38-实战-计数系统-2

在上一节课中我带你了解了如何设计一套支撑高并发访问和存储大数据量的通用计数系统,我们通过缓存技术、消息队列技术以及对于 Redis 的深度改造,就能够支撑万亿级计数数据存储以及每秒百万级别读取请求了。然而有一类特殊的计数并不能完全使用我们提到的方案,那就是未读数。

未读数也是系统中一个常见的模块,以微博系统为例,你可看到有多个未读计数的场景,比如:

  • 当有人 @你、评论你、给你的博文点赞或者给你发送私信的时候,你会收到相应的未读提醒;
  • 在早期的微博版本中有系统通知的功能,也就是系统会给全部用户发送消息,通知用户有新的版本或者有一些好玩的运营活动,如果用户没有看,系统就会给他展示有多少条未读的提醒。
  • 我们在浏览信息流的时候,如果长时间没有刷新页面,那么信息流上方就会提示你在这段时间有多少条信息没有看。

那当你遇到第一个需求时,要如何记录未读数呢?其实,这个需求可以用上节课提到的通用计数系统来实现,因为二者的场景非常相似。

你可以在计数系统中增加一块儿内存区域,以用户 ID 为 Key 存储多个未读数,当有人 @ 你时,增加你的未读 @的计数;当有人评论你时,增加你的未读评论的计数,以此类推。当你点击了未读数字进入通知页面,查看 @ 你或者评论你的消息时,重置这些未读计数为零。相信通过上一节课的学习,你已经非常熟悉这一类系统的设计了,所以我不再赘述。

那么系统通知的未读数是如何实现的呢?我们能用通用计数系统实现吗?答案是不能的,因为会出现一些问题。

系统通知的未读数要如何设计

来看具体的例子。假如你的系统中只有 A、B、C 三个用户,那么你可以在通用计数系统中增加一块儿内存区域,并且以用户 ID 为 Key 来存储这三个用户的未读通知数据,当系统发送一个新的通知时,我们会循环给每一个用户的未读数加 1,这个处理逻辑的伪代码就像下面这样:

List<Long> userIds = getAllUserIds();
for(Long id : userIds) {
  incrUnreadCount(id);
}

这样看来,似乎简单可行,但随着系统中的用户越来越多,这个方案存在两个致命的问题。

首先,获取全量用户就是一个比较耗时的操作,相当于对用户库做一次全表的扫描,这不仅会对数据库造成很大的压力,而且查询全量用户数据的响应时间是很长的,对于在线业务来说是难以接受的。如果你的用户库已经做了分库分表,那么就要扫描所有的库表,响应时间就更长了。不过有一个折中的方法, 那就是在发送系统通知之前,先从线下的数据仓库中获取全量的用户 ID,并且存储在一个本地的文件中,然后再轮询所有的用户 ID,给这些用户增加未读计数。

这似乎是一个可行的技术方案,然而它给所有人增加未读计数,会消耗非常长的时间。你计算一下,假如你的系统中有一个亿的用户,给一个用户增加未读数需要消耗 1ms,那么给所有人都增加未读计数就需要 100000000 * 1 /1000 = 100000 秒,也就是超过一天的时间;即使你启动 100 个线程并发的设置,也需要十几分钟的时间才能完成,而用户很难接受这么长的延迟时间。

另外,使用这种方式需要给系统中的每一个用户都记一个未读数的值,而在系统中,活跃用户只是很少的一部分,大部分的用户是不活跃的,甚至从来没有打开过系统通知,为这些用户记录未读数显然是一种浪费。

通过上面的内容,你可以知道为什么我们不能用通用计数系统实现系统通知未读数了吧?那正确的做法是什么呢?

要知道,系统通知实际上是存储在一个大的列表中的,这个列表对所有用户共享,也就是所有人看到的都是同一份系统通知的数据。不过不同的人最近看到的消息不同,所以每个人会有不同的未读数。因此,你可以记录一下在这个列表中每个人看过最后一条消息的 ID,然后统计这个 ID 之后有多少条消息,这就是未读数了。

NAME

这个方案在实现时有这样几个关键点:

  • 用户访问系统通知页面需要设置未读数为 0,我们需要将用户最近看过的通知 ID 设置为最新的一条系统通知 ID;
  • 如果最近看过的通知 ID 为空,则认为是一个新的用户,返回未读数为 0;
  • 对于非活跃用户,比如最近一个月都没有登录和使用过系统的用户,可以把用户最近看过的通知 ID 清空,节省内存空间。

这是一种比较通用的方案,即节省内存,又能尽量减少获取未读数的延迟。 这个方案适用的另一个业务场景是全量用户打点的场景,比如像下面这张微博截图中的红点。

NAME

这个红点和系统通知类似,也是一种通知全量用户的手段,如果逐个通知用户,延迟也是无法接受的。 因此你可以采用和系统通知类似的方案。

首先,我们为每一个用户存储一个时间戳,代表最近点过这个红点的时间,用户点了红点,就把这个时间戳设置为当前时间;然后,我们也记录一个全局的时间戳,这个时间戳标识最新的一次打点时间,如果你在后台操作给全体用户打点,就更新这个时间戳为当前时间。而我们在判断是否需要展示红点时,只需要判断用户的时间戳和全局时间戳的大小,如果用户时间戳小于全局时间戳,代表在用户最后一次点击红点之后又有新的红点推送,那么就要展示红点,反之,就不展示红点了。

NAME

这两个场景的共性是 全部用户共享一份有限的存储数据,每个人只记录自己在这份存储中的偏移量,就可以得到未读数了。

你可以看到,系统消息未读的实现方案不是很复杂,它通过设计避免了操作全量数据未读数,如果你的系统中有这种打红点的需求,那我建议你可以结合实际工作灵活使用上述方案。

最后一个需求关注的是微博信息流的未读数,在现在的社交系统中,关注关系已经成为标配的功能,而基于关注关系的信息流也是一种非常重要的信息聚合方式,因此,如何设计信息流的未读数系统就成了你必须面对的一个问题。

如何为信息流的未读数设计方案

信息流的未读数之所以复杂主要有这样几点原因。

  • 首先,微博的信息流是基于关注关系的,未读数也是基于关注关系的

    就是说,你关注的人发布了新的微博,那么你作为粉丝未读数就要增加 1。如果微博用户都是像我这样只有几百粉丝的 「小透明」 就简单了,你发微博的时候系统给你粉丝的未读数增加 1 不是什么难事儿。但是对于一些动辄几千万甚至上亿粉丝的微博大 V 就麻烦了,增加未读数可能需要几个小时。假设你是杨幂的粉丝,想了解她实时发布的博文,那么如果当她发布博文几个小时之后,你才收到提醒,这显然是不能接受的。所以未读数的延迟是你在设计方案时首先要考虑的内容。

  • 其次,信息流未读数请求量极大、并发极高,这是因为接口是客户端轮询请求的,不是用户触发的。也就是说,用户即使打开微博客户端什么都不做,这个接口也会被请求到。在几年前,请求未读数接口的量级就已经接近每秒 50 万次,这几年随着微博量级的增长,请求量也变得更高。而作为微博的非核心接口,我们不太可能使用大量的机器来抗未读数请求,因此,如何使用有限的资源来支撑如此高的流量是这个方案的难点。

  • 最后,它不像系统通知那样有共享的存储,因为每个人关注的人不同,信息流的列表也就不同,所以也就没办法采用系统通知未读数的方案。

那要如何设计能够承接每秒几十万次请求的信息流未读数系统呢?你可以这样做:

  • 首先,在通用计数器中记录每一个用户发布的博文数;
  • 然后在 Redis 或者 Memcached 中记录一个人所有关注人的博文数快照,当用户点击未读消息重置未读数为 0 时,将他关注所有人的博文数刷新到快照中;
  • 这样,他关注所有人的博文总数减去快照中的博文总数就是他的信息流未读数。
NAME

假如用户 A,像上图这样关注了用户 B、C、D,其中 B 发布的博文数是 10,C 发布的博文数是 8,D 发布的博文数是 14,而在用户 A 最近一次查看未读消息时,记录在快照中的这三个用户的博文数分别是 6、7、12,因此用户 A 的未读数就是(10-6)+(8-7)+(14-12)=7。

这个方案设计简单,并且是全内存操作,性能足够好,能够支撑比较高的并发,事实上微博团队仅仅用 16 台普通的服务器就支撑了每秒接近 50 万次的请求,这就足以证明这个方案的性能有多出色,因此,它完全能够满足信息流未读数的需求。

当然了这个方案也有一些缺陷,比如说快照中需要存储关注关系,如果关注关系变更的时候更新不及时,那么就会造成未读数不准确;快照采用的是全缓存存储,如果缓存满了就会剔除一些数据,那么被剔除用户的未读数就变为 0 了。但是好在用户对于未读数的准确度要求不高(未读 10 条还是 11 条,其实用户有时候看不出来),因此,这些缺陷也是可以接受的。

通过分享未读数系统设计这个案例,我想给你一些建议:

  1. 缓存是提升系统性能和抵抗大并发量的神器,像是微博信息流未读数这么大的量级我们仅仅使用十几台服务器就可以支撑,这全都是缓存的功劳;
  2. 要围绕系统设计的关键困难点想解决办法,就像我们解决系统通知未读数的延迟问题一样;
  3. 合理分析业务场景,明确哪些是可以权衡的,哪些是不行的,会对你的系统设计增益良多,比如对于长久不登录用户,我们就会记录未读数为 0,通过这样的权衡,可以极大地减少内存的占用,减少成本。

课程小结

以上就是本节课的全部内容了,本节课我带你了解了未读数系统的设计,这里你需要了解的重点是:

  • 评论未读、@未读、赞未读等一对一关系的未读数可以使用上节课讲到的通用计数方案来解决;

    通用计数器:一个用户一类的计数用一个字段存储就可以了,相对简单,就是存储量很大

  • 在系统通知未读、全量用户打点等存在有限的共享存储的场景下,可以通过记录用户上次操作的时间或者偏移量,来实现未读方案;

    内容共享,通过最后一次读时间或则 id 的偏移量来计算,要注意的是:这种未读不是指你有 10 篇没有读的文章,读取一篇就减少 1 这种未读统计,而是你总共有多少未读消息数量,点击后就重置为 0 了

  • 最后,信息流未读方案最为复杂,采用的是记录用户博文数快照的方式。

    这里和系统全量打点的方式类似,也是点击未读后,计数器就重置为 0 了。但是它有关系计算这是和全量打点方式不一样的地方

这里你可以看到,这三类需求虽然都和未读数有关,但是需求场景不同、对于量级的要求不同,设计出来的方案也就不同。因此,就像我刚刚提到的样子,你在做方案设计的时候,要分析需求的场景,比如说数据的量级是怎样的,请求的量级是怎样的,有没有一些可以利用的特点(比如系统通知未读场景下的有限共享存储、信息流未读场景下关注人数是有限的等等),然后再制定针对性的方案,切忌盲目使用之前的经验套用不同的场景,否则就可能造成性能的下降,甚至危害系统的稳定性。

4.37 - CH39-实战-信息流-1

前两节课中,我带你探究了如何设计和实现互联网系统中一个常见模块——计数系统。它的业务逻辑其实非常简单,基本上最多只有三个接口,获取计数、增加计数和重置计数。所以我们在考虑方案的时候考察点也相对较少,基本上使用缓存就可以实现一个兼顾性能、可用性和鲁棒性的方案了。然而大型业务系统的逻辑会非常复杂,在方案设计时通常需要灵活运用多种技术,才能共同承担高并发大流量的冲击。那么接下来,我将带你了解如何设计社区系统中最为复杂、并发量也最高的信息流系统。这样,你可以从中体会怎么应用之前学习的组件了。

最早的信息流系统起源于微博,我们知道,微博是基于关注关系来实现内容分发的 ,也就是说,如果用户 A 关注了用户 B,那么用户 A 就需要在自己的信息流中,实时地看到用户 B 发布的最新内容,这是微博系统的基本逻辑,也是它能够让信息快速流通、快速传播的关键。 由于微博的信息流一般是按照时间倒序排列的,所以我们通常把信息流系统称为 TimeLine(时间线)。那么当我们设计一套信息流系统时需要考虑哪些点呢?

设计信息流系统的关注点有哪些

首先,我们需要 关注延迟数据 ,也就是说,你关注的人发了微博信息之后,信息需要在短时间之内出现在你的信息流中。

其次,我们需要考虑如何支撑高并发的访问。信息流是微博的主体模块,是用户进入到微博之后最先看到的模块,因此它的并发请求量是最高的,可以达到每秒几十万次请求。

最后,信息流拉取性能直接影响用户的使用体验。微博信息流系统中需要聚合的数据非常多,你打开客户端看一看,想一想其中需要聚合哪些数据?主要是微博的数据,用户的数据,除此之外,还需要查询微博是否被赞、评论点赞转发的计数、是否被关注拉黑等等。聚合这么多的数据就需要查询多次缓存、数据库、计数器,而在每秒几十万次的请求下,如何保证在 100ms 之内完成这些查询操作,展示微博的信息流呢?这是微博信息流系统最复杂之处,也是技术上最大的挑战。

那么我们怎么设计一套支撑高并发大流量的信息流系统呢?一般来说,会有两个思路:一个是基于推模式,另一个是基于拉模式。

如何基于推模式实现信息流系统

什么是推模式呢?推模式是指用户发送一条微博后,主动将这条微博推送给他的粉丝,从而实现微博的分发,也能以此实现微博信息流的聚合。

假设微博系统是一个邮箱系统,那么用户发送的微博可以认为是进入到一个发件箱,用户的信息流可以认为是这个人的收件箱。推模式的做法是在用户发布一条微博时,除了往自己的发件箱里写入一条微博,同时也会给他的粉丝收件箱里写入一条微博。

假如用户 A 有三个粉丝 B、C、D,如果用 SQL 表示 A 发布一条微博时系统做的事情,那么就像下面展示的这个样子:

insert into outbox(userId, feedId, create_time) values("A", $feedId, $current_time); // 写入 A 的发件箱
insert into inbox(userId, feedId, create_time) values("B", $feedId, $current_time); // 写入 B 的收件箱
insert into inbox(userId, feedId, create_time) values("C", $feedId, $current_time); // 写入 C 的收件箱
insert into inbox(userId, feedId, create_time) values("D", $feedId, $current_time); // 写入 D 的收件箱

当我们要查询 B 的信息流时,只需要执行下面这条 SQL 就可以了:

select feedId from inbox where userId = "B";

如果你想要提升读取信息流的性能,可以把收件箱的数据存储在缓存里面,每次获取信息流的时候直接从缓存中读取就好了。

推模式存在的问题和解决思路

你看,按照这个思路就可以实现一套完整的微博信息流系统,也比较符合我们的常识。但是,这个方案会存在一些问题。

首先,就是消息延迟。在讲系统通知未读数的时候,我们曾经提到过,不能采用遍历全量用户给他们加未读数的方式,原因是遍历一次全量用户的延迟很高,而推模式也有同样的问题。对明星来说,他们的粉丝数庞大,如果在发微博的同时还要将微博写入到上千万人的收件箱中,那么发微博的响应时间会非常慢,用户根本没办法接受。因此,我们一般会使用消息队列来消除写入的峰值,但即使这样,由于写入收件箱的消息实在太多,你还是有可能在几个小时之后才能够看到明星发布的内容,这会非常影响用户的使用体验。

NAME

在推模式下,你需要关注的是微博的写入性能,因为用户每发一条微博,都会产生多次的数据库写入。为了尽量减少微博写入的延迟,我们可以从两方面来保障。

  • 一方面,在消息处理上,你可以启动多个线程并行地处理微博写入的消息。
  • 另一方面,由于消息流在展示时可以使用缓存来提升读取性能,所以我们应该尽量保证数据写入数据库的性能,必要时可以采用写入性能更好的数据库存储引擎。

比如,我在网易微博的时候就是采用推模式来实现微博信息流的。当时为了提升数据库的插入性能,我们采用了 TokuDB 作为 MySQL 的存储引擎,这个引擎架构的核心是一个名为分形树的索引结构(Fractal Tree Indexes)。我们知道数据库在写入的时候会产生对磁盘的随机写入,造成磁盘寻道,影响数据写入的性能;而分形树结构和我们在11 讲中提到的 LSM 一样,可以将数据的随机写入转换成顺序写入,提升写入的性能。另外,TokuDB 相比于 InnoDB 来说,数据压缩的性能更高,经过官方的测试,TokuDB 可以将存储在 InnoDB 中的 4TB 的数据压缩到 200G,这对于写入数据量很大的业务来说也是一大福音。然而,相比于 InnoDB 来说,TokuDB 的删除和查询性能都要差一些,不过可以使用缓存加速查询性能,而微博的删除频率不高,因此这对于推模式下的消息流来说影响有限。

其次,存储成本很高。在这个方案中我们一般会这么来设计表结构:

先设计一张 Feed 表,这个表主要存储微博的基本信息,包括微博 ID、创建人的 ID、创建时间、微博内容、微博状态(删除还是正常)等等,它使用微博 ID 做哈希分库分表;

另外一张表是用户的发件箱和收件箱表,也叫做 TimeLine 表(时间线表),主要有三个字段,用户 ID、微博 ID 和创建时间。它使用用户的 ID 做哈希分库分表。

NAME

由于推模式需要给每一个用户都维护一份收件箱的数据,所以数据的存储量极大,你可以想一想,谢娜的粉丝目前已经超过 1.2 亿,那么如果采用推模式的话,谢娜每发送一条微博就会产生超过 1.2 亿条的数据,多么可怕! 我们的解决思路是: 除了选择压缩率更高的存储引擎之外,还可以定期地清理数据,因为微博的数据有比较明显的实效性,用户更加关注最近几天发布的数据,通常不会翻阅很久之前的微博,所以你可以定期地清理用户的收件箱,比如只保留最近 1 个月的数据就可以了。

除此之外,推模式下我们还通常会遇到扩展性的问题。在微博中有一个分组的功能,它的作用是你可以将关注的人分门别类,比如你可以把关注的人分为明星、技术、旅游等类别,然后把杨幂放入明星分类里,将 InfoQ 放在技术类别里。 那么引入了分组之后,会对推模式有什么样的影响呢? 首先是一个用户不止有一个收件箱,比如我有一个全局收件箱,还会针对每一个分组再分别创建一个收件箱,而一条微博在发布之后也需要被复制到更多的收件箱中了。

如果杨幂发了一条微博,那么不仅需要插入到我的收件箱中,还需要插入到我的「明星」收件箱中,这样不仅增加了消息分发的压力,同时由于每一个收件箱都需要单独存储,所以存储成本也就更高。

最后,在处理取消关注和删除微博的逻辑时会更加复杂。比如当杨幂删除了一条微博,那么如果要删除她所有粉丝收件箱中的这条微博,会带来额外的分发压力,我们还是尽量不要这么做。

而如果你将一个人取消关注,那么需要从你的收件箱中删除这个人的所有微博,假设他发了非常多的微博,那么即使你之后很久不登录,也需要从你的收件箱中做大量的删除操作,有些得不偿失。 所以你可以采用的策略是: 在读取自己信息流的时候,判断每一条微博是否被删除以及你是否还关注这条微博的作者,如果没有的话,就不展示这条微博的内容了。使用了这个策略之后,就可以尽量减少对于数据库多余的写操作了。

那么说了这么多,推模式究竟适合什么样的业务的场景呢? 在我看来,它比较适合于一个用户的粉丝数比较有限的场景,比如说微信朋友圈,你可以理解为我在微信中增加一个好友是关注了他也被他关注,所以好友的上限也就是粉丝的上限(朋友圈应该是 5000)。有限的粉丝数可以保证消息能够尽量快地被推送给所有的粉丝,增加的存储成本也比较有限。如果你的业务中粉丝数是有限制的,那么在实现以关注关系为基础的信息流时,也可以采用推模式来实现。

课程小结

以上就是本节课的全部内容了。本节课我带你了解以推模式实现信息流的方案以及这个模式会存在哪些问题和解决思路,这里你需要了解的重点是:

  1. 推模式就是在用户发送微博时,主动将微博写入到他的粉丝的收件箱中;
  2. 推送信息是否延迟、存储的成本、方案的可扩展性以及针对取消关注和微博删除的特殊处理是推模式的主要问题;
  3. 推模式比较适合粉丝数有限的场景。

你可以看到,其实推模式并不适合微博这种动辄就有上千万粉丝的业务,因为这种业务特性带来的超高的推送消息延迟以及存储成本是难以接受的,因此,我们要么会使用基于拉模式的实现,要么会使用基于推拉结合模式的实现。那么这两种方案是如何实现的呢?他们在实现中会存在哪些坑呢?又要如何解决呢?我将在下节课中带你着重了解。

4.38 - CH40-实战-信息流-2

在前一节课中,我带你了解了如何用推模式来实现信息流系统,从中你应该了解到了推模式存在的问题,比如它在面对需要支撑很大粉丝数量的场景时,会出现 消息推送延迟、存储成本高、方案可扩展性差等问题 。虽然我们也会有一些应对的措施,比如说选择插入性能更高的数据库存储引擎来提升数据写入速度,降低数据推送延迟;定期删除冷数据以减小存储成本等等,但是由于微博大 V 用户粉丝量巨大,如果我们使用推模式实现信息流系统,那么只能缓解这些用户的微博推送延迟问题,没有办法彻底解决。

这个时候你可能会问了:那么有没有一种方案可以一劳永逸地解决这个问题呢?当然有了,你不妨试试用拉模式来实现微博信息流系统。那么具体要怎么做呢?

如何使用拉模式设计信息流系统

所谓拉模式,就是指用户主动拉取他关注的所有人的微博,将这些微博按照发布时间的倒序进行排序和聚合之后,生成信息流数据的方法。

按照这个思路实现微博信息流系统的时候你会发现:用户的收件箱不再有用,因为信息流数据不再出自收件箱,而是出自发件箱。发件箱里是用户关注的所有人数据的聚合。因此用户在发微博的时候就只需要写入自己的发件箱,而不再需要推送给粉丝的收件箱了,这样在获取信息流的时候,就要查询发件箱的数据了。

这个逻辑我还用 SQL 的形式直观地表达出来,方便你理解。假设用户 A 关注了用户 B、C、D,那么当用户 B 发送一条微博的时候,他会执行这样的操作:

insert into outbox(userId, feedId, create_time) values("B", $feedId, $current_time); // 写入 B 的发件箱

当用户 A 想要获取他的信息流的时候,就要聚合 B、C、D 三个用户收件箱的内容了:

select feedId from outbox where userId in (select userId from follower where fanId = "A") order by create_time desc

你看,拉模式的实现思想并不复杂,并且相比推模式来说,它有几点明显的优势。

首先,拉模式彻底解决了推送延迟的问题,大 V 发微博的时候不再需要推送到粉丝的收件箱,自然就不存在延迟的问题了。

其次,存储成本大大降低了。在推模式下,谢娜的粉丝有 1.2 亿,那么谢娜发送一条微博就要被复制 1.2 亿条,写入到存储系统中。在拉模式下只保留了发件箱,微博数据不再需要复制,成本也就随之降低了。

最后,功能扩展性更好了。比如,微博增加了分组的功能,而你想把关注的 A 和 B 分成一个单独的组,那么 A 和 B 发布的微博就形成了一个新的信息流,这个信息流要如何实现呢?很简单,你只需要查询这个分组下所有用户(也就是 A 和 B),然后查询这些用户的发件箱,再把发件箱中的数据,按照时间倒序重新排序聚合就好了。

List<Long> uids = getFromGroup(groupId); // 获取分组下的所有用户
Long<List<Long>> ids = new ArrayList<List<Long>>();
for(Long id : uids) {
  ids.add(getOutboxByUid(id)); // 获取发件箱的内容 id 列表
}
return merge(ids); // 合并排序所有的 id

拉模式之所以可以解决推模式下的所有问题,是因为在业务上关注数始终是有上限的,那么它是不是一个无懈可击的方案呢? 当然不是,拉模式也会有一些问题,在我看来主要有这样两点。

第一点,不同于推模式下获取信息流的时候,只是简单地查询收件箱中的数据,在拉模式下,我们需要对多个发件箱的数据做聚合,这个查询和聚合的成本比较高。微博的关注上限是 2000,假如你关注了 2000 人,就要查询这 2000 人发布的微博信息,然后再对查询出来的信息做聚合。

那么,如何保证在毫秒级别完成这些信息的查询和聚合呢?答案还是缓存。我们可以把用户发布的微博 ID 放在缓存中,不过如果把全部用户的所有微博都缓存起来,消耗的硬件成本也是很高的。所以我们需要关注用户浏览信息流的特点,看看是否可能对缓存的存储成本做一些优化。

在实际执行中,我们对用户的浏览行为做了抽量分析,发现 97% 的用户都是在浏览最近 5 天之内的微博 ,也就是说,用户很少翻看五天之前的微博内容,所以我们只缓存了每个用户最近 5 天发布的微博 ID。假设我们部署 6 个缓存节点来存储这些微博 ID,在每次聚合时并行从这几个缓存节点中批量查询多个用户的微博 ID,获取到之后再在应用服务内存中排序后就好了,这就是对缓存的 6 次请求,可以保证在 5 毫秒之内返回结果。

第二,缓存节点的带宽成本比较高。你想一下,假设微博信息流的访问量是每秒 10 万次请求,也就是说,每个缓存节点每秒要被查询 10 万次。假设一共部署 6 个缓存节点,用户人均关注是 90,平均来说每个缓存节点要存储 15 个用户的数据。如果每个人平均每天发布 2 条微博,5 天就是发布 10 条微博,15 个用户就要存储 150 个微博 ID。每个微博 ID 要是 8 个字节,150 个微博 ID 大概就是 1kB 的数据,单个缓存节点的带宽就是 1kB * 10 万 = 100MB,基本上跑满了机器网卡带宽了。 那么我们要如何对缓存的带宽做优化呢?

在14 讲中我提到,部署多个缓存副本提升缓存可用性,其实,缓存副本也可以分摊带宽的压力。我们知道在部署缓存副本之后,请求会先查询副本中的数据,只有不命中的请求才会查询主缓存的数据。假如原本缓存带宽是 100M,我们部署 4 组缓存副本,缓存副本的命中率是 60%,那么主缓存带宽就降到 100M * 40% = 40M,而每组缓存副本的带宽为 100M / 4 = 25M,这样每一组缓存的带宽都降为可接受的范围之内了。

NAME

在经过了上面的优化之后,基本上完成了基于拉模式信息流系统方案的设计,你在设计自己的信息流系统时可以参考借鉴这个方案。另外,使用缓存副本来抗流量也是一种常见的缓存设计思路,你在项目中必要的时候也可以使用。

推拉结合的方案是怎样的

但是,有的同学可能会说:我在系统搭建初期已经基于推模式实现了一套信息流系统,如果把它推倒重新使用拉模式实现的话,系统的改造成本未免太高了。有没有一种基于推模式的折中的方案呢?

其实我在网易微博的时候,网易微博的信息流就是基于推模式来实现的,当用户的粉丝量大量上涨之后, 我们通过对原有系统的改造实现了一套推拉结合的方案,也能够基本解决推模式存在的问题,具体怎么做呢?

方案的核心在于大 V 用户在发布微博的时候,不再推送到全量用户,而是只推送给活跃的用户。这个方案在实现的时候有几个关键的点。

首先,我们要如何判断哪些是大 V 用户呢? 或者说,哪些用户在发送微博时需要推送全量用户,哪些用户需要推送活跃用户呢?在我看来,还是应该以粉丝数作为判断标准,比如,粉丝数超过 50 万就算作大 V,需要只推送活跃用户。

其次,我们要如何标记活跃用户呢? 活跃用户可以定义为最近几天内在微博中有过操作的用户,比如说刷新过信息流、发布过微博、转发评论点赞过微博,关注过其他用户等等,一旦有用户有过这些操作,我们就把他标记为活跃的用户。

而对大 V 来说,我们可以存储一个活跃粉丝的列表,这个列表里面就是我们标记的活跃用户。当某一个用户从不活跃用户变为活跃用户时,我们会查询这个用户的关注者中哪些是大 V,然后把这个用户写入到这些大 V 的活跃粉丝列表里面,这个活跃粉丝列表是定长的,如果活跃粉丝数量超过了长度,就把最先加入的粉丝从列表里剔除,这样可以保证推送的效率。

最后,一个用户被从活跃粉丝列表中剔除,或者是他从不活跃变成了活跃后,由于他不在大 V 用户的活跃粉丝列表中,所以也就不会收到微博的实时推送,因此,我们需要异步地把大 V 用户最近发布的微博插入到他的收件箱中,保证他的信息流数据的完整性。

NAME

采用推拉结合的方式可以一定程度上弥补推模式的缺陷,不过也带来了一些维护的成本,比如说系统需要维护用户的在线状态,还需要多维护一套活跃的粉丝列表数据,在存储上的成本就更高了。

因此,这种方式一般适合中等体量的项目,当粉丝量级在百万左右,活跃粉丝数量在 10 万级别时,一般可以实现比较低的信息传播延迟以及信息流获取延迟,但是当你的粉丝数量继续上涨,流量不断提升之后,无论是活跃粉丝的存储还是推送的延迟都会成为瓶颈,所以改成拉模式会更好的支撑业务。

课程小结

以上就是本节课的全部内容了。本节课我带你了解了基于拉模式和推拉结合模式实现信息流系统的方案,这里你需要了解的几个重点是:

  1. 在拉模式下,我们只需要保存用户的发件箱,用户的信息流是通过 聚合关注者发件箱数据 来实现的;
  2. 拉模式会有比较大的聚合成本,缓存节点也会存在带宽的瓶颈,所以我们可以通过一些权衡策略尽量减少获取数据的大小,以及部署缓存副本的方式来抗并发;
  3. 推拉结合的模式核心是只推送活跃的粉丝用户,需要维护用户的在线状态以及活跃粉丝的列表,所以需要增加多余的空间成本来存储,这个你需要来权衡。

拉模式和推拉结合模式比较适合微博这种粉丝量很大的业务场景,因为它们都会有比较可控的消息推送延迟。你可以看到,在这两节课程中我们灵活使用数据库分库分表、缓存消息队列、发号器等技术,实现了基于推模式、拉模式以及推拉结合模式的信息流系统,你在做自己系统的方案设计时,应该充分发挥每种技术的优势,权衡业务自身的特性,最终实现技术和业务上的平衡,也就是既能在业务上满足用户需求,又能在技术上保证系统的高性能和高可用。

5 - 数据密集系统

数据密集型系统——https://github.com/Vonng/ddia

6 - 远程过程调用

6.1 - RPC 概览

目前互联网系统都是微服务化,那么就需要 RPC 调用,因此本文梳理了从 RPC 基本框架协议到整个服务化框架体系建设中所包含的知识点,重点在于 RPC 框架 和 服务治理能力的梳理,本文定位于一个科普性质的文章,在于让大家了解一个全貌。

一、RPC 基本框架

1-1、RPC 基本框架

理解 RPC

RPC 的概念就是远程过程调用。我们本地的函数调用,就是 A 方法调 B 方法,然后得到调用的结果,RPC 就是让你像本地函数调用一样进行跨服务之间的函数调用。互联网发展到现在,我们都在讲微服务,服务都拆分为微服务了,那么相关依赖的调用,就会变成跨服务之间的调用,而他们之间的通信方式就是依靠 RPC。

RPC 基础结构(RPC 协议)

Nelson 的论文 Implementing Remote Procedure Calls 告诉我们, RPC 协议包括 5 个部分:

  1. Client
  2. Client-stub
  3. RPCRuntime
  4. Server-stub
  5. Server
NAME

当 Client 发起一个远程调用时,它实际上是调用本地的 Stub。本地 stub 负责将调用的接口、方法和参数,通过约定的协议规范进行编码,并通过本地的 RPCRuntime 进行传输,然后将数据包发送到网络上传输出去。当 Server 端的 RPCRuntime 收到请求后,交给 Server-Stub 进行解码,然后调用 Server 端的函数或者方法,执行完毕就开始返回结果,Server-Stub 将返回结果编码后,发送给 Client,Client 端的 RPCRuntime 收到结果,发给 Client-Stub 解码得到结果,返回给 Client。

这里面分了三个层次:

  • 对于客户端和服务端,都和原来本地调用一样,只需要关注自身的业务逻辑。
  • 对于 Stub 层,处理双方约定好的语法、语义、封装、解封装。
  • 对于 RPCRuntime,主要处理高性能的传输,以及网络的错误和异常。

1-2、RPC 框架的重点

从 RPC 基础结构中,我们总结出 RPC 框架的重点,包括 4 部分,如下:

1-2-1、数据序列化

序列化就是将数据结构或对象转换成二进制的过程,也就是编码的过程,序列化后数据才方便进行网络传输;反序列化就是在序列化过程中所生成的二进制转换成数据结构或者对象的过程,将二进制转换为对象后业务才好进行后续的逻辑处理。

常见的序列化协议如下:

  • ProtoBuf(IDL)
  • JSON
  • XML
  • Hessian2 (JAVA 系)

常见的 RPC 框架如 gRPC、Thrift、Dubbo、RPCX 、Motan 等会支持上述协议中的大部分,尤其是 ProtoBuf 和 JSON 。目前从性能上和使用广泛度上来看,现在一般推荐使用 ProtoBuf,当然很多自研的框架里面他们也会自己实现他们自己的序列化协议。

1-2-2、网络传输(网络通信)

在数据被序列化为二进制后就可以行网络传输了,网络传输就是我们的数据怎么传输到对方服务器上,目前来说,常见的通信传输方式包括 :TCP、UDP、HTTP(HTTP2.0)、QUIC 协议,TCP 是大部分框架都会默认支持的,额外这里要说明一下,RPCX 支持 QUIC 而 gRPC 支持 HTTP2.0。

QUIC(Quick UDP Internet Connection)是谷歌制定的一种互联网传输层协议,它基于 UDP 传输层协议,同时兼具 TCP、TLS、HTTP/2 等协议的可靠性与安全性,可以有效减少连接与传输延迟,更好地应对当前传输层与应用层的挑战。QUIC 在应用程序层面就能实现不同的拥塞控制算法,不需要操作系统和内核支持,这相比于传统的 TCP 协议,拥有了更好的改造灵活性,非常适合在 TCP 协议优化遇到瓶颈的业务。

1-2-3、RPC 调用方式

网络传输只是数据传输非常基础的一方面,从业务上来看,我们发起一次 RPC 调用,那么还需要 RPC 的调用方式,包括如下三大类:

  • 同步 RPC:最常用的服务调用方式,发起调用请求后同步等待结果,符合我们开发的一贯认知和习惯。开发简单、容易维护、容易理解。

  • 异步 RPC:客户端发起服务调用之后,不同步等待响应,而是注册监听器或者回调函数,待接收到响应之后发起异步回调,驱动业务流程继续执行,实现起来相对复杂,但是高并发场景下性能会更好。

  • 并行 RPC:并行服务调用,一次 I/O 操作,可以发起批量调用,这个并行的批量请求一般是通过协程来实现,然后同步等待响应;

    • 这里需要注意,这个 并行 RPC 和 stream 流式调用是有区别的,流式是说,批量发送请求后,可以不必等所有的消息全收到后才开始响应,而是接收到第一条消息的时候就可以及时的响应。

1-2-4、服务治理

RPC 协议只是定义了 Client 与 Server 之间的点对点调用流程,包括 stub、通信协议、RPC 消息解析等部分。但是在实际应用中,远程过程调用的时候还需要考虑服务的路由、负载均衡、高可用等问题,而保障服务之间的调用就需要进行服务治理,服务治理基本就涵盖:服务注册和发现、限流、降级、熔断、重试、失败处理、负载均衡等各种服务治理策略。

到这里,RPC 框架的重点的 4 大部分就介绍完毕了,现在再来看看,常见的 RPC 框架:

1-3、常见 RPC 框架

RPC 框架就是在 RPC 协议的基础上,来完善一些偏向业务实际应用的功能,从而满足不同场景的业务诉求。综合来看,目前业界的 RPC 框架大致有两种不同的侧重方向,一种偏向于服务治理型,一种偏向于跨语言调用型。

1-3-1、服务治理型 RPC 框架

业界比较出名的服务治理型的 RPC 框架有 Dubbo、DubboX、Motan、RPCX 等。

服务治理型 RPC 框架的特点是功能丰富,提供高性能的远程调用以及服务发现、服务治理等功能;常用于微服务化的业务系统中,对于特定语言的项目可以十分友好的透明化接入,是当前业界的主流。但缺点是语言耦合度较高,跨语言支持难度较大。

1-3-2、跨语言调用型 RPC 框架

业界比较出名的跨语言调用型的 RPC 框架有 :

跨语言调用型 RPC 框架的重点是关注于服务的跨语言调用,能够支持我们常见的大部分的语言进行语言无关的调用,非常适合于为不同语言提供通用远程服务的场景,但这类框架没有服务发现、服务治理等机制,使用这些框架的时候需要我们自己来实现服务发现、服务治理等相关策略。

那么,跨语言调用指的是啥意思呢,具体是:客户端和服务端可以在各种环境中运行和相互通信,并且可以用框架支持的任何语言编写(参考 gRPC 官网中的一张图如下,比如 C++ 的服务可以调用 Ruby 的服务:)

NAME

1-3-3、常见 RPC 框架对比

NAME

二、通用的服务化框架设计

我们一般讲的微服务框架包含了 RPC 框架,微服务体系中最重要的就是 RPC 框架,并且是一般是偏向服务治理的 RPC 框架。微服务需要提供的核心能力包括:微服务架构中通讯的基础协议 RPC、服务发现与注册、负载均衡、容错、熔断、限流、降级、权限、全链路日志跟踪。

2-1、微服务框架的核心能力(服务治理策略)

2-1-1、服务注册与发现

微服务后,服务大量增加,因此我们一定要能够有一个合适的方案能够发现对方的所有服务,业界比较常见的服务发现的组件如 zookeeper、etcd、consul 等,基本原理就是先将自己的服务列表到注册中心注册,然后再提供服务发现能力。

服务发现机制有服务端发现和客户端发现两种实现方式:

  • 服务端发现模式(server-side):可以通过 DNS 或者带 VIP 的负载均衡实现。

    • 优点是对客户端无侵入性,客户端只需要简单的向负载均衡或者服务域名发起请求,无需关系服务发现的具体细节,也不用引入服务发现的逻辑
    • 缺点是不灵活,不方便异化处理;并且同时需要引入一个统一的负载均衡器。
  • 客户端发现模式(client-side):需要客户端到服务注册中心查询服务地址列表,然后再决定通过哪个地址请求服务。

    • 灵活性更高,可以根据客户端的诉求进行满足自身业务的负载均衡,但是客户端需要引入服务发现的逻辑,同时依赖服务注册中心
    • 常见服务注册组件包括:zookeeper、Etcd、Consul。java 系的一般选择 zookeeper ,而 Golang 的一般选择 consul 或 etcd ,这个也就是各自选择对应的语言。etcd 相比而言,是用的较多的,K8s 系统里面也基于是 etcd。

2-1-2、服务路由 & 负载均衡

服务路由和服务发现紧密相关,服务路由一般不会设计成一个独立运行的系统,通常情况下是和服务发现放在一起实现的。在服务路由中,最关键的能力就是负载均衡。我们一般常见的负载均衡算法有:随机路由、轮询路由、hash、权重、最小压力路由、最小连接数路由、就近路由等。

从业界来看,负载均衡的实现方案一般可以分为三类:

  • 服务端负载均衡:

    • 负载均衡器在一台单独的主机上,可以采用软负载,如 Nginx,LVS 等,也可以采用硬负载,如 F5 等
    • 实现简单,存在单点问题,所有的流量都需要通过负载均衡器,如果负载均衡器存在问题,则直接导致服务不能正常提供服务;中间经过负载均衡器做代理,性能也有一定损耗。
  • 客户端负载均衡

    • 解决了服务端负载的单点问题,每个客户端都实现了自己的负载功能,负载能力和客户端进程在一起
    • 负载均衡要求每个客户端自己实现,如果不同的技术栈,每个客户端则需要使用不同的语言实现自己的负载能力。
    • 目前业界主流的 微服务框架都是采用 客户端负载均衡方案
  • 客户端主机独立负载均衡

    • 服务发现和负载的能力从客户端进程移出,客户端进程和负载均衡进程是 2 个独立的进程,在同一个主机上。也就是 SideCar 模式
    • 没有单点问题,如果一个主机的负载均衡器出问题,只影响一个节点调用,不影响其他的节点,负载均衡器本身负载也较小,性能损耗较低

2-1-3、服务容错

负载均衡和容错是服务高可用的重要手段。服务容错的设计有个基本原则,就是“Design for Failure”。常见的服务容错策略如请求重试、限流、降级、熔断、隔离

超时与重试

超时机制算是一种最常见的服务容错模式了,我们发起的任何请求调用,都不可能无限等待,对方服务可能因为各种原因导致请求不能及时响应,因此超时机制是最基础并且是必须的。超时可能有网络超时、也可能是对方服务异常等多种情况。

重试一般和超时模式结合使用,适用于对于下游服务的数据强依赖的场景,通过重试来保证数据的可靠性或一致性,不强依赖的场景不建议使用。在对方服务超时之后,可以根据情况进行重试(对方服务返回异常就不要重试了)。但是一定注意,重试不能盲目重试,在重试的设计中,我们一般都会引入,Exponential Backoff 的策略,也就是 “指数级退避”,每一次重试所需要的 sleep 时间都会指数增加,否则可能会导致拖累到整个系统。

服务限流

限流和降级用来保证核心服务的稳定性;限流是指限制每个服务的最大访问量、降级是指高峰期对非核心的系统进行降级从而保证核心服务的可用性

限流的实现方式:

  • 计数器方式(最简单)

  • 队列算法

    • 常规队列:FIFO
    • 优先级队列
    • 带权重队列
  • 漏斗(漏桶)算法 Leaky Bucket

  • 令牌桶算法 Token Bucket

  • 基于响应时间的动态限流

    • 参考 TCP 协议中算法:TCP 使用 RTT 来探测网络的延时和性能,从而设定相应的“滑动窗口”的大小

分布式限流和单机限流:

  • 单机限流:单机限流参考上面的实现方式可以发现有多种限流算法可供选择,但是业界我们最常用的是漏桶算法及令牌桶算法。如果要对线上并发总数进行严格限定的话,漏桶算法可能会更合适一些。

  • 分布式限流(集群限流):集群限流的情况要更复杂一些,一般是中心化的设计。

    • 简单的实现可以基于 Redis 来做,但是方案的缺点显而易见,每取一次令牌都会进行一次网络开销,而网络开销起码是毫秒级,所以这种方案支持的并发量是非常有限的。
    • 另外一个简单的实现思路是先在各个微服务节点上实现一个计数器,对单位时间片内的调用进行计数,单个节点的计数量定期推送汇总,然后由中心化的统计服务来计算这个时间片的总调用量,集群限流分析器会拿到这个总调用量,并和预先定义的限流阈值进行比对,计算出一个限流比例,这个限流比例会通过服务注册中心下发到各个服务节点上,服务节点基于限流比例会各自算出当前节点对应的最终限流阈值,最后利用单机限流进行流控。
    • 分布式限流业界常用的框架包括 Hystrix、resilience4j
容错降级

容错降级可以分为三大类,从小到大依次是:

  • 接口降级:最小的降级类别。对非核心接口,在需要降级的时候,可以直接返回空或者异常,以减少高峰期这些非核心接口对资源如 CPU、内存、磁盘、网络的占用和消耗
  • 功能降级:对非核心功能,在需要降级的时候,可以直接执行本地逻辑,不做跨服务、跨网络访问;也可设置降级开关,一键关闭指定功能,保全整体稳定;还可以通过熔断机制实现。
  • 服务降级:对非核心服务,可以通过服务治理框架根据错误率或者响应时间自动触发降级策略;还可以通过断路器实现
熔断

熔断设计来源于日常生活中的电路系统,在电路系统中存在一种熔断器(Circuit Breaker),它的作用就是在电流过大时自动切断电路。熔断器一般要实现三个状态:闭合、断开和半开,分别对应于正常、故障和故障后检测故障是否已被修复的场景。

  • 闭合:正常情况,后台会对调用失败次数进行积累,到达一定阈值或比例时则自动启动熔断机制。
  • 断开:一旦对服务的调用失败次数达到一定阈值时,熔断器就会打开,这时候对服务的调用将直接返回一个预定的错误,而不执行真正的网络调用。同时,熔断器需要设置一个固定的时间间隔,当处理请求达到这个时间间隔时会进入半熔断状态。
  • 半开:在半开状态下,熔断器会对通过它的部分请求进行处理,如果对这些请求的成功处理数量达到一定比例则认为服务已恢复正常,就会关闭熔断器,反之就会打开熔断器。

熔断设计的一般思路是,在请求失败 N 次后在 X 时间内不再请求,进行熔断;然后再在 X 时间后恢复 M% 的请求,如果 M% 的请求都成功则恢复正常,关闭熔断,否则再熔断 Y 时间,依此循环。

在熔断的设计中,根据 Netflix 的开源组件 hystrix 的设计,最重要的是三个模块:熔断请求判断算法、熔断恢复机制、熔断报警:

  • 熔断请求判断机制算法:根据事先设置的在固定时间内失败的比例来计算。
  • 熔断恢复:对于被熔断的请求,每隔 X 时间允许部分请求通过,若请求都成功则恢复正常。
  • 熔断报警:对于熔断的请求打异常日志和监控,异常请求超过某些设定则报警
隔离

隔离,也就是 Bulkheads 隔板的意思,这个术语是用在造船上的,也就是船舱里防漏水的隔板。

在服务化框架的隔离设计中,我们同样是采用类似的技术来让我们的故障得到隔离。因此这里的重点就是需要我们对系统进行分离。一般来说,有两种方式,一种是以服务的类型来做分离,一种是以用户来做分离。

  • 以服务的种类来做分离的方式:比如一个社交 APP,服务类型包括账号系统、聊天系统,那么可以通过不同系统来做隔离

  • 以用户来做分离的方式:比如通过策略来实现不同的用户访问到不同的实例

2-1-4、集群容错

在分布式场景下,我们的服务在集群中的都是有冗余的,一个是为容错,一个是为了高并发,针对大量服务实例的情况下,因此就有了集群容错的设计。集群容错是微服务集群高可用的保障,它有很多策略可供选择,包括:

  • 快速失败(Failfast):快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
  • 失败转移(Failover):失败自动切换,当出现失败,重试集群其它服务实例 。通常用于读操作,但重试会带来更长延迟。一般都会设置重试次数。
  • 失败重试(Failback):失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
  • 聚合调用(Forking):并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。一般会设置最大并行数。
  • 广播调用(Broadcast):广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。

2-2、微服务框架的基础能力

服务监控和告警

开源代表作:Prometheus + Grafana,遵循 OpenMetrics 规范,基本数据格式分为 Gauge、Count、Summary、Histogram

分布式服务 Tracing 跟踪系统

目前有两种协议规范:

  • OpenTracing :链路跟踪领域的标准,目前业界系统支持最多的标准,开源代表作:

    • jaeger
    • zipkin
  • OpenTelemetry:可观测性领域的标准,对 Trace,Metrics,Log 统一支持的唯一标准。OpenTelemetry 由 OpenTracing 和 OpenCensus 合并而成,和 OpenTracing 是一个互补的形态。

    • 天机阁
配置中心

配置中心用来管理大量微服务之间的业务配置,并且是中心化的统一配置中心来进行管理。

远程日志

远程日志组件的代表作是 ELK 系统:Elasticsearch, Logstash, Kibana。

在微服务架构中,一个客户端请求的接入,往往涉及到后端一系列服务的调用,如何将这些请求串联起来?业界常用的方案是采用全局流水号【traceID】串联起来。通过全局流水号【traceID】,从日志里面可以拉出整条调用链路。

这里关于整体链路又和 分布式服务 Tracing 跟踪系统 关联起来,Tracing 可以知道整体链路的请求质量,远程日志+ traceID 可以知道整体链路的日志详情。

2-3、微服务框架依托的自动化运维能力

微服务框架建设 ok 之后,那么大量服务怎么运维,这就依托自动化运维能力,包括如下几个方面:

  • 自动化测试
  • 自动化部署
  • 生命周期管理

业界目前一般采用容器平台,微服务框架 + K8s 容器平台 是当今互联网业务的黄金标准

2-4、小结:自己搭建一个服务化框架的思路

自己搭建一个服务化框架的思路:

  • 首先,要确定好基本的 RPC 通信协议,一般会选择开源方案,重点关注:

    • 功能需求的满足度
    • 多语言的支持
    • 性能和稳定性
    • 社区活跃度、成熟度
  • 其次,基于开源的 RPC 框架来搭建而不是完全从 0 开始。可选的框架包括

    • Dubbo
    • Motan
    • gRPC
    • Thrift

7 - 领域驱动设计

7.1 - DDD 概念精要

领域驱动设计不是新鲜的概念,至今已有十六年时间,一直来不曾大行其道,直到 IT 行业内掀起微服务的狂潮,技术界才重新审视和意识到领域驱动设计的价值。不能说微服务拯救了领域驱动设计,但确实是微服务,让领域驱动设计又重新焕发了青春。DDD 是一个非常庞大的建模和设计体系,这篇文章只在理论和概念上阐述 DDD 的价值、方法和架构,欢迎任何的问题指正和补充。

DDD 价值

应对复杂业务

引起软件系统复杂度的主要因素是需求,软件系统需求又可以分两个方面:业务需求和技术需求 。我们分析系统的复杂度时就可以从业务复杂度和技术复杂度这两个维度出发。

业务复杂度跟系统的业务需求规模和需求之间的关系层级有直接关系,需求的数量和关系的层级决定代码的规模和逻辑循环或递归的层级,系统的需求数量越大,需求之间的关系越复杂,系统的业务复杂度就越大。John Ousterhout 的著作《A Philosophy of Software Design》从认知的负担和开发工作量的角度来定义软件系统的复杂度,并给出了一个复杂度公式:

20220220223658

子模块的复杂度(cp)乘以该模块对应的开发时间权重值(tp),累加后得到系统的整体复杂度(C)。可以看到系统整体的复杂度并不简单等于所有子模块复杂度的累加,还要考虑该模块的开发维护所花费的时间在整体时间中的权重占比(tp),这个权重比就跟模块划分是否内聚、设计是否优雅有直接关系。

技术复杂度则来自于对软件系统运行的质量需求,包括安全、高性能、高并发、高可用和高扩展性。系统安全性要求对访问进行控制,无论是加密还是认证和授权,都需要为整个系统架构添加额外的间接层。不仅对访问的低延迟产生影响,还极大提升了系统代码复杂度;为了让后端系统能具备高扩展性和弹性,要求所有系统的设计必须是无状态的;为了提升用户端访问体验,后端需要增添离线任务对数据加工、异构、预热、预缓存,以实现用空间换时间,降低实时接口的逻辑复杂度来降低请求的延迟。然而最让开发者更抓狂的是这些技术需求彼此又是相互影响甚至相互矛盾,在一些复杂流程并要求高响应的业务场景,如下单、秒杀等,会将一个同步的访问请求拆分为多级步骤的异步请求,再通过引入消息中间件对这些请求进行整合和分散处理,这种分离一方面增加了系统架构的复杂性,另一方面也因为引入了更多的资源,使得系统的高可用面临挑战,并增加了维护数据一致性的难度。而且技术复杂度与业务复杂度并非孤立,二者复杂度因子混合在一起产生的负作用更让系统的复杂度变得不可预期,难以掌控,就好比氢气和氯气混合在一起遇到光亮发生爆炸一样。

DDD 的核心思想就是要避免业务逻辑的复杂度与技术实现的复杂度混淆在一起,确定业务逻辑与技术实现的边界,从而隔离各自的复杂度,业务逻辑并不关心技术是如何实现的。无论采用何种技术,只要业务需求不变,业务规则就不会变化。理想状态下,应该保证业务逻辑与技术实现是正交的。

DDD 通过分层架构与六边形架构确保业务逻辑与技术实现的隔离。

DDD 战略设计指导我们面对客户的业务需求,由领域专家与开发团队展开充分的交流,经过需求分析与知识提炼,获得清晰的问题域,在引入限界上下文和上下文映射对问题域进行合理的分解,识别出核心领域与子领域,并确定领域的边界以及它们之间的关系,从而把一个大的复杂系统问题拆分成多个细粒度、独立和内聚的业务子问题,从而很好地分解和控制业务复杂度,各个小组聚焦各自的子领域中。

在架构方面,通过分层架构来隔离关注点,将领域实现独立出来,利于领域模型的单一性与稳定性;

引入六边形架构清晰地界定领域与技术基础设施的边界;CQRS 模式则分离了查询场景和命令场景,针对不同场景选择使用同步或异步操作,提高架构的低延迟性与高并发能力。

分层架构

“分层架构”遵循了“关注点分离”原则,将属于业务逻辑的关注点放到领域层(Domain Layer)中,而将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中。同时,领域驱动设计又颇具创见的引入了应用层(Application Layer)。应用层扮演了双重角色。一方面它作为业务逻辑的外观(Facade),暴露了能够体现业务用例的应用服务接口;另一方面它又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。下图展现的就是一个典型的领域驱动设计分层架构。蓝色区域的内容与业务逻辑有关,灰色区域的内容与技术实现有关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来。

20220220223735

六边形架构

由 Cockburn 提出的六边形架构则以“内外分离”的方式,更加清晰地勾勒出业务逻辑与技术实现的边界,且将业务逻辑放在了架构的核心位置。这种架构模式改变了我们观察系统架构的视角。体现业务逻辑的应用层与领域层处于六边形架构的内核,并通过内部的六边形边界与基础设施的模块隔离开。当我们在进行软件开发时,只要恪守架构上的六边形边界,就不会让技术实现的复杂度污染到业务逻辑,保证了领域的整洁。边界还隔离了变化产生的影响。如果我们在领域层或应用层抽象了技术实现的接口,再通过依赖注入将控制的方向倒转,业务内核就会变得更加的稳定,不会因为技术选型或其他决策的变化而导致领域代码的修改。

快速响应业务变化

不确定性和变化是这个时代的主旋律,业务需要快速上线,并根据用户的反馈不停地调整和升级,有生命力的业务主动寻求变化,不变则亡是很多行业目前的共识,企业应对变化的响应力成了成败的关键。同时一个长期困扰软件研发的问题是,需求总是在变化,无论预先设计如何“精确”,总是发现下一个坑就在不远处。相信很多技术人员都有这样的经历,架构和响应能力越来越糟糕,也就是我们常说的架构腐化了,最后大家不得不接受重写。软件架构设计的另一个关键方面是让系统能够更快地响应外界业务的变化,并且使得系统能够持续演进。在遇到变化时不需要从头开始,保证实现成本得到有效控制。

DDD 的核心是从业务出发、面向业务变化构建软件架构,实质是保证面对业务变化时我们能够有足够快的响应能力。面向业务变化而架构就要求首先理解业务的核心问题,即有针对性地进行关注点分离来找到相对内聚的业务活动形成子问题域。让每个字问题的划分尽可能靠近变化的原点,子问题域内部是相对稳定的,未来的变化频率不会很高,是符合深模块特性的,而子问题边界是很容易变化的。DDD 最后在实现层面利用成熟的技术模式屏蔽掉技术细节的复杂度。

与微服务相得益彰

Martin Fowler 和 James Lewis 提出微服务时,提出了微服务的 9 大架构特质,指导组织围绕业务组建团队,把业务拆分为一个个业务上高度内聚、技术上松散耦合、运行在独立进程中的小型服务,微服务架构赋予了每个服务业务上的敏捷性和技术上的自主性,因此可以针对每个服务进行独立地迭代、更新、部署和弹性扩展,从而缩短需求交付周期并加速创新。

在面对复杂业务和快速变化需求时,DDD 从业务视角进行关注点分离和应对复杂度,让业务具备更高的响应力。DDD 战略设计阶段,引入限界上下文(Bounded Context)和上下文映射(Context Map)对问题域进行合理的分解,确定领域的边界以及它们之间的关系,维持模型的完整性。

限界上下文不仅限于对领域模型的控制,而在于分离关注点之后,使得整个上下文可以成为独立部署的设计单元,这就是“微服务”的概念,上下文映射的诸多模式则对应了微服务之间的协作。因此在战略设计阶段,微服务扩展了领域驱动设计的内容,反过来领域驱动设计又能够保证良好的微服务设计。

边界给了实现限界上下文内部的最大自由度。这也是战略设计在分治上起到的效用,我们可以在不同的限界上下文选择不同的架构模式和技术实现,这也正好映照了微服务的特点:在技术架构上,系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

ThoughtWorks 公司技术专家编写的《微服务设计》书中,专门有一章节“限界上下文”,充分说明微服务的落地需要 DDD 来辅助的,起码在建模阶段是需要借助 DDD 强大的战略模式来支撑的。微服务不是简单的指将服务尽可能的拆小,然后一个 RPC 框架搞定了,这太粗糙了,无法落地。

20220220223806

辅助中台战略落地

领域驱动设计让参与者基于统一语言沟通和协作,围绕一个统一和一致的领域模型工作,传统的分析模型和设计模型不再割裂;显式地把业务领域和设计放到了软件开发的核心,软件人员和业务人员合作来构建领域模型,使得软件的交付质量更高且维护成本更低;利用限界上下文来分解问题域,识别核心领域,有效分解和控制了业务的复杂度;

利用 DDD 提倡的分层、六边形等架构,分离了业务复杂度和技术复杂度,使得系统具备更强的扩展性和弹性;战术层面提供了元模型(聚合,实体,值对象,服务,工厂,仓储)帮助构建清晰、稳定,能快速响应变化和新需求能力的应用;

DDD 构建的应用能快速方便地切到微服务;领域驱动设计给企业应用带来的稳定性、灵活性、扩展性和应对变化的响应力对于建立灵活前台、稳固中台能带来巨大的帮助作用。

DDD 过程

领域驱动设计是一套面对复杂业务进行建模和设计的方法论和实践,建立了以领域为核心驱动力的设计体系。领域驱动设计分为 2 个主要过程:战略设计、战术设计 。

20220220223845

在战略设计阶段 ,面对纷繁复杂的业务需求,领域专家和研发团队进行紧密合作、充分沟通,进行事件风暴或场景驱动设计,分析需求并提炼知识,得到比较清晰的问题域,输出由领域专家和研发团队达成共识的统一语言(UL,Ubiquitous Language),基于统一语言对问题域进行分析和建模,识别业务边界,确定限界上下文,根据限界上下文划分独立的领域,建立限界上下文彼此之间的关系,接着引入系统上下文(System Context)确定系统的边界,并确定它的外部环境,包括与其集成的第三方系统与基础设施。利用 DDD 分层架构或六边形架构界定业务领域和技术实现的边界,让稳定的核心领域模型处于架构的最内部,避免技术实现和架构变动带来的影响。

接着进入战术设计阶段 ,一个大的业务问题被分解为多个限界上下文(问题域),团队视野和专注就可以聚焦到每一个内聚的限界上下文,进行战术设计。战术设计的重点是利用领域驱动设计的元模型对领域的复杂性进行分解和建模。

领域驱动设计强调和突出了领域模型的重要性,通过整个领域驱动设计过程,绑定领域模型和技术模型,以保证领域模型和技术模型在贯穿整个软件开发的生命周期中(需求分析、建模、架构、设计、编码、测试与持续重构)的强一致性。领域模型指导着软件设计以及技术编码实现,接着通过重构实践来挖掘隐式概念,完善统一语言和模型,运用设计模式改进设计与开发质量。以下是领域驱动设计的粗略过程:

20220220223858

战略设计

提炼问题域

回顾我们往日的分析和解决问题过程, 面对复杂问题,很多同学还没完全理解问题的全貌就已经在提出解决办法,这些解决办法只是针对问题的局部,经典图书《第五项修炼》把这种行为称为“反应式”的,碰到一个问题给出一个回应办法,而从这些问题整体来看这种方式会阻碍团队找出最佳解决方案。

DDD 作为一种建模和架构方法,最大的突破是着重明确了区分了问题域和解决方案域,对业务问题的认知不是技术人员最擅长的,很多研发在碰到需求时,脑子本能就闪现表、类、服务、架构,把解决方案当终极问题来追求,而 DDD 要求研发进行痛苦的蜕变,在业务分析和领域建模阶段忘记技术解决方案。同时 DDD 要求领域专家和技术人员坐在一起通力合作、密切沟通来分析和建模,领域专家对业务有着深刻的理解,技术人员擅长技术实现和架构设计,而领域专家和技术人员由于工种的差异导致交流产生障碍,开发人员满脑子是技术语言,领域专家脑子也都是业务概念,如果按照本能基于自己的专业背景进行沟通,效率太低了,即使有翻译的角色也会产生理解偏差, DDD 的一个核心原则是所有人员包括领域专家和技术的进行任何沟通都使用一种基于模型的通用语言(UL,Ubiquitous Language),在代码中也是这样。

DDD 帮助技术人员对需求进行本质思考和理解,关注点不在是聚焦在功能上,而是理解需求的真正意图和愿景,而非开发一个 feature,更深层次地理解隐含的愿景才能开发出真正地解决问题和创造价值的系统来。在提炼问题域过程中,领域专家和技术专家通过充分交流,进行需求分析和知识提炼,获得清晰的问题子域,识别出核心域、通用域、支撑域。通用域是开发该软件系统根本竞争力所在,也是领域建模的重心,建议分配最精锐的研发;

通用域 是指多个子域依赖的通用功能子域,比如权限、邮件、日志系统等;支撑域 是指系统中非核心域和通用域的业务域。

需求分析时从用例开始,列出达成业务目标需要的步骤,切忌跳转到解决方案上,识别出用于构建模型的知识,通过 UML 表示分析模型和业务模型,形成业务和技术人员达成共识的通用语言。

该阶段领域专家只专注于问题域而不是解决方案,业务和技术人员基于 UL 沟通,并且考虑投入产出比,团队只为核心业务进行领域驱动设计并创建 UL,订单系统为下单模块进行 DDD,订单监控模块用普通的事务脚本方式来即可,我们通天塔的活动模板和积木业务非常复杂和核心,非常适合使用 DDD 来建模和架构设计,而通天塔后端的 Man 系统是面向开发者进行后端和线上业务监控的,进行 DDD 就是小题大做。

识别限界上下文(Bounded Context)

Eric Evans 说:“对一个大型系统,领域模型的完全统一将是不可行的或者不划算的。”。DDD 的构建块不能盲目地应用在一个无限大的领域模型上,一个无限大的领域模型也无助于我们开发出优质的软件,限界上下文是分解领域模型的关键。限界上下文是一种“分而治之”的思维,也是一种高层的抽象机制,让人们对领域进行本质思考,简化问题和应对复杂性。

限界上下文如同细胞,细胞是上下文,细胞壁是边界,细胞内的信息负责对代谢和遗传进行调控,细胞壁对细胞起着支持和保护防御的作用,控制物质进出,让对细胞有用的物质不能出来,有害的物质也不能进入细胞。而领域驱动设计中的限界上下文保证领域模型的一致性和完整性,清晰边界的控制力保证了领域的安全和稳定。

如何识别限界上下文?

明确了系统的问题域和业务期望后,梳理出主要的业务流程,这些业务流程体现了各种参与者在这个过程中通过业务活动共同协作,最终完成具有业务价值的领域功能。业务流程结合了参与角色(Who)、业务活动(What)和业务价值(Why)。在业务流程的基础上,我们就可以抽象出不同的业务场景,这些业务场景又由多个业务活动组成,可以利用领域场景分析方法剖析场景,以帮助我们识别业务活动,例如采用用例对场景进行分析,此时,一个业务活动实则就是一个用例。业务流程是一个由多个用户角色参与的动态过程,而业务场景则是这些用户角色执行业务活动的静态上下文。

接下来,我们利用领域场景分析的用例分析方法剖析这些场景。通过参与者(Actor)来驱动对用例的识别,这些参与者恰好就是参与到场景业务活动的角色。根据用例描述出来的业务活动应该与统一语言一致,最好直接从统一语言中撷取。一旦准确地用统一语言描述出这些业务活动,我们就可以从语义相关性和功能相关性两个方面识别业务边界,进而提炼出初步的限界上下文。

20220220223937

从不同角度看待限界上下文,限界上下文会呈现出对不同对象的控制力。

  • 领域逻辑层面:限界上下文确定了领域模型的业务边界,维护了模型的完整性与一致性,从而降低系统的业务复杂度。
  • 团队合作层面:限界上下文确定了团队的工作边界,建立了团队之间的合作模式,提升了团队间的协作效率,“康威定律”告诉我们,系统设计(产品结构)等同组织形式,每个设计系统的组织,其产生的设计等同于组织之间的沟通结构,限界上下文指导产生的团队结构的工作模式是最高效的。
  • 技术架构层面:限界上下文确定了系统架构的应用边界,保证了系统层和上下文领域层各自的一致性,建立了上下文之间的集成方式。微服务中,限界上下文指导技术人员划分微服务的边界,通常一个限界上下文作为一个在独立进程中运行的微服务。

DDD 驱动我们把每一个限界上下文设计成一个个“自治”的单元,自治要满足四个特点:

20220220223951
  • 最小完备 是实现自治的基本条件,指的是自治单元履行的职责是根据业务价值的完整性和最小功能集进行设计的,这让自治单元无需求助其他自治单元获得信息,避免了不必要的依赖关系,同时也避免了不必要和不合适的职责添加到该自治单元上。
  • 自我履行意味着由自治单元自身决定要做什么。是否应该履行某职责,由限界上下文拥有的信息来决定。站在自治单元的角度去思考:“如果我拥有了这些信息,我究竟应该履行哪些职责?”这些职责属于当前上下文的活动范围,一旦超出,就该毫不犹豫地将不属于该范围的请求转交给别的上下文。自我履行其实意味着对知识的掌握,为避免风险,你要履行的职责一定是你掌握的知识范畴之内。
  • 稳定空间 指的是减少外界变化对限界上下文内部的影响。稳定空间符合开放封闭原则(OCP),即对修改是封闭的,对扩展是开放的,该原则其实体现了一个单元的封闭空间与开放空间。封闭空间体现为对细节的封装与隐藏,开放空间体现为对共性特征的抽象与统一,二者共同确保了整个空间的稳定。
  • 独立进化 指的是减少限界上下文的变化对外界的影响。用限界上下文的上下游关系来阐释,则稳定空间寓意下游限界上下文,无论上游怎么变,我自岿然不动。要做到独立进化,就必须保证对外公开接口的稳定性,因为这些接口被众多消费者依赖和调用,一旦发生变更,就会牵一发而动全身。一个独立进化的限界上下文,需要一个稳定、设计良好的接口设计,并在版本上考虑了兼容与演化。

最小完备是基础,只有赋予了限界上下文足够的信息,才能保证它的自我履行。稳定空间与独立进化则一个对内一个对外,是对变化的有效应对,而它们又是通过最小完备和自我履行来保障限界上下文受到变化的影响最小。

上下文映射

限界上下文仅是一种对领域问题域的静态划分,还缺少一个重要的关注点,即:限界上下文之间是如何协作的?当我们发现彼此协作存在问题时,说明限界上下文的划分出现了问题,也是识别限界上下文的一种验证方法。Eric Evans 将这种体现限界上下文协作方式的要素称之为“上下文映射(Context Map)”,并给出了 9 种上下文映射关系:

20220220224024

Open Host Service 相当于微服务之间的协作关系;防腐层(Anti-Corruption)是一种高度防御性的策略,结合门面(Facade)模式和适配器(Adapter)设计模式,将模型与其需要集成的其他模型隔离开来,以防止被频繁变更或不稳定的依赖模型污染和腐败。

20220220224039

架构设计

“DDD 不需要特殊的架构,只要是能将技术问题与业务问题分离的架构即可。” – Eric Evans

传统的三层架构分而治之、降低耦合、提高复用,但存在弊端,业务逻辑在不同层泄露,导致替换某一层变得困难、难以对核心逻辑完整测试。领域驱动设计给出了 DDD 分层架构、六边形架构、整洁架构等分层架构,它们遵循“关注点分离”原则,旨在分离和隔离业务复杂度和技术复杂度,凸显了领域模型,保证了领域模型的稳定性和一致性。

DDD 分层架构

DDD 分层架构将属于业务逻辑的关注点放到领域层(Domain Layer)中,将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中,DDD 创新性地引入了应用层(Application Layer),应用层扮演了两重角色。一作为业务逻辑的门面(Facade),暴露了能够体现业务用例的应用服务接口,又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。下图展现的是一个典型的领域驱动设计分层架构。蓝色区域和业务逻辑相关,灰色区域与技术实现相关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来。

20220220224100

我们详细介绍 DDD 分层架构中每一层的用意和设计:

表现层(User Interface Layer):负责向用户显示信息和解释用户命令,完成前端界面逻辑应用层(Application Layer) 很薄的一层,负责展现层与领域层之间的协调,不包含任何的业务逻辑和业务规则,也不保留业务对象的状态,是对领域服务的编排和转发。应用层扮演了两重角色。一作为业务逻辑的门面(Facade),暴露了能够体现业务用例的应用服务接口,又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。一个 Application Service 代表一个 Use Case,一个 Use Case 代表了一个完整的业务场景,对于外部的客户来说,应用层是与客户协作的应用服务,接口代表是业务的含义。

我们知道 DDD 分层架构的主要目标是分离业务复杂度与技术复杂度,应用层扮演的就是这样的分界线。从设计模式的角度来理解,应用层的 Application Service 是一个 Facade,对外部客户,作为代表 Use Case 的整体应用,对架构内部,它负责整合领域层的领域逻辑与非业务相关的横切关注点。

应用中,存在与具体的业务逻辑无关,在整个系统中会被诸多服务调用的横切关注点实现,他们在职责上是内聚的,散布在所有代码层次中,包括异常处理、事务、监控、日志、认证和授权等。所以与横切关注点协作的服务应被定义为应用服务。

领域层(Domain Layer),是业务软件的核心所在,也是软件架构的核心,包含了业务所涉及的领域对象(实体、值对象)、领域服务,负责表达业务概念、业务状态信息以及业务规则,具体表现形式就是领域模型。领域驱动设计提倡富领域模型,将业务逻辑归属到领域对象上。基础设施层(Infrastructure Layer):基础层为各层提供通用的技术能力,包括:为应用层传递消息、提供 API 管理,为领域层提供数据库持久化机制等。它还能通过技术框架来支持各层之间的交互。

整洁架构(Clean Architecture)

整洁架构中,同心圆代表应用软件架构的不同部分,也是一种以领域模型为中心的架构,从里到外依次是 Entities、Use Cases、Interface Adapters、Frameworks and Drivers。整洁架构明确了各层的依赖关系,越往里,依赖越低,越抽象,外圆代码依赖只能指向内圆,内圆不知道外圆的任何事情。

20220220224125

六边形架构(Hexagonal Architecture)

又称为端口-适配器,六边形架构也是一种分层架构,不是从上下或左右分,而是从内部和外部来分。六边形架构在领域驱动设计和微服务架构设计中扮演了较重要的角色。六边形架构将系统分为内部(内部六边形)和外部,内部代表了应用的业务逻辑,外部代表应用的驱动逻辑、基础设施(诸如 REST,SOAP,NoSQL,SQL,Message Queue 等)或其他应用,UI 层、DB 层、和各种中间件层实际上是没有本质上区别的,都只是数据的输入和输出。内部通过端口和外部系统通信,端口代表了一定协议,以 API 呈现。

一个端口对应多个适配器,对应多个外部系统,对这一类外部系统的归纳,不同的外部系统需要使用不同的适配器,适配器负责对协议进行转换。六边形架构有一个明确的关注点,一开始就强调把重心放在业务逻辑上,外部的驱动逻辑或被驱动逻辑存在可变性、可替换性,依赖具体技术细节。而核心的业务领域相对稳定,体现应用的核心价值。六边形的六并没有实质意义,只是为了留足够的空间放置端口和适配器,一般端口数不会超过 4 个。适配器可以分为 2 类,“主”、“从”适配器,也可称为“驱动者”和“被驱动者”。

代码依赖只能使由外向内。对于驱动者适配器(也称主适配器,Driving Adapter),就是外部依赖内部的。但是对于被驱动者适配器(也称次适配器,Driven Adapter),实际是内部依赖外部,这时需要使用依赖倒置,由驱动者适配器将被驱动者适配器注入到应用内部,这时端口的定义在应用内部,但是实现是由适配器实现。

20220220224141

CQRS(命令与查询职责分离)

CQRS 使用分离的接口将数据查询操作(Queries)和数据修改操作(Commands)分离开来,这也意味着在查询和更新过程中使用的数据模型也是不一样的,这样读和写逻辑就隔离开来了。使用 CQRS 分离了读写职责之后,可以对数据进行读写分离操作来改进性能,可扩展性和安全。DDD 和 CQRS 结合,可以分别对读和写建模:

20220220224158

查询模型是一种非规范化数据模型,不反映领域行为,只用于数据查询和显示。命令模型执行领域行为,在领域行为执行完成后通知查询模型。如果查询模型和领域模型共享数据源,则可以省略这一步;如果没有共享数据源,可以借助于发布订阅的消息模式通知到查询模型,从而达到数据最终一致性。对于写少读多的共享类通用数据服务(如主数据类应用)可以采用读写分离架构模式。单数据中心写入数据,通过发布订阅模式将数据副本分发到多数据中心。通过查询模型微服务,实现多数据中心数据共享和查询。

通天塔从系统维度对数据库进行了读写分离,通天塔的 C 端应用和服务大部分是读场景,CMS 是多写应用,所以 CMS 的写走主库,读服务按照使用场景不同访问不同的从库,实时请求、同步数据到集市、数据中心等,这点也从数据库基础架构上保证了通天塔系统的低延时和稳定。

综述

六边形架构的内部六边形、DDD 分层架构的领域层和应用层、以及整洁架构 Use Cases 和 Entities 区域实现了核心业务逻辑。但是核心业务逻辑又由两部分来完成:应用层和领域层逻辑。领域层实现了最核心的业务领域部分的逻辑,对外提供领域模型内细粒度的领域服务,应用层依赖领域层业务逻辑,通过服务组合和编排通过 API 网关向前台应用提供粗粒度的服务。业务需求变幻莫测,但我们总能在这些变化找出一些规律,用户体验、操作交互、以及业务流程的变化,往往只会导致 UI 层和流程的变化,总体来说,不管前端和外部如何变化,核心领域逻辑基本不会大变。把握好这个规律,我们就知道如何设计应用层和领域层,如何进行逻辑划界了。架构模型正是通过分层方式来控制需求变化对系统的影响,确保从外向里受的影响逐步减小。面向用户端的展现层可以快速响应外部需求进行调整和发布,灵活多变;应用层通过服务组合和编排实现业务流程的快速适配上线,以满足不同的业务场景;领域层是经过抽象和提炼的业务原子单元,是非常稳定的。这些架构设计的好处是可以保证领域层的核心业务逻辑不会因为外部需求和流程的变动而调整,对于建立前台灵活、中台稳固的架构能力是很有好处的。下面是 Herberto Graca 的一张包含了六边形、整洁、CQRS 等架构的综合图,全面的说明了这些架构的设计要点和不同的出发点。

20220220224222

战术设计

战略设计为我们提供一种高层视角来审视我们的软件系统,而战术设计则将战略设计的成果具体化和细节化,它关注的是单个限界上下文内部技术层面的实施。DDD 给我们提供了一整套技术工具集,包括实体、值对象、领域服务和资源库等,如下:

20220220224252

行为饱满的领域对象

让我们先看几个概念:

  • 失血模型 :是仅包含属性的 getter/setter 方法的数据载体,没有行为和动作,业务逻辑由服务层完成。贫血模型:包括了属性、getter/setter 方法,和不依赖于持久化的原子领域逻辑,依赖于持久层的业务逻辑将会放到服务层中。
  • 充血模型:包含了属性、getter/setter 方法、大部分的业务逻辑,包括依赖于持久层的业务逻辑,所以使用充血模型的领域层是依赖于持久层,服务层是很薄的一层,仅仅封装事务和少量逻辑。
  • 胀血模型:取消了 Service 层,胀血模型就是把和业务逻辑不相关的其他应用逻辑(如授权、事务等)都放到领域模型中。

胀血模型是显而易见不可取的,这里不做过多讨论。失血模型是绝大数企业开发应用的模式,一些火热的 ORM 工具比如 Hibernate,Entity Framework 实际上助长了失血模型的扩散,而且传统三层架构中的服务层,承受了太多的职责,如事务管理、业务逻辑、权限检查等,这违反了单一职责原则和关注分离原则,并且产生了大量的依赖和循环依赖,当业务复杂度上升时,服务层所包含的代码将会非常庞大和复杂,直接导致了维护成本和测试成本的上升。同时也会导致业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由失血症引起的失忆症,它会导致系统变得愈发复杂和难以维护。

采用领域模型的开发方式,将数据和业务逻辑封装在一起,从服务层移动到领域将业务逻辑模型中,这样服务层可以只负责应用逻辑(事务、日志、认证、监控、编排等),领域模型可以专门负责其相关的业务逻辑,相关的业务分别内聚到不同的领域模型中,与现实领域的业务对象映射,一些很有可能重复的业务代码都会被集中到一处,降低重复代码,提升业务逻辑的复用、可测试性和维护性。贫血模型和充血模型都是满足数据+行为的,应该采用哪种模式,大家这是一个争论了旷日持久的问题,关注点还是在于领域模型是否要依赖持久层,我个人还是偏重于贫血模式,依赖持久层就意味着单元测试的展开要更加困难,而且领域对象的生命周期应该交给外部模型才更合理。

领域驱动设计元模型

实体(Entity) 实体是一种具有唯一身份标识的对象,具有持续的生命周期,除唯一标识其他属性是可变的。实体通过它的唯一标识被区分。例如实体订单 Order,标识为 oderId,通天塔的活动实体 Activity,标识为 activityId。

  • 值对象(Value Object) 当我们只关心一个模型元素的属性时,应把它归类为值对象。应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。建议将值对象设计成一个不变(Immutable)对象,这样就不需要担心并发带来的诸如同步、冲突等问题了,这既降低了编程的难度,又可以无需引入额外的同步锁影响程序的性能。也不要为它分配任何标识,这样应用也无需去管理值对象的生命周期。值对象通过比较其属性(equals)区分是否是相同值对象。应该尽量使用值对象来建模而不是实体对象。在领域驱动设计中,提倡尽量定义值对象来替代基本类型,因为基本类型无法体现统一语言中的领域概念。假设一个实体定义了许多属性,这些属性都是基本类型,就会导致与这些属性相关的领域行为都要放到实体中,导致实体的职责变得不够单一。引入值对象后情况就不同了,我们可以利用合理的职责分配,将这些职责(领域行为)按照内聚性分配到各个值对象中,这个领域模型就能变得协作良好。值对象可以与其所在的实体对象保存在同一张表中,值对象的每一个属性保存为一列;值对象也可以独立于其所在的实体对象保存在另一张表中,值对象获得委派主键,该主键对客户端是不可见的。
  • 聚合(Aggregate) 聚合中所包含的对象之间具有密不可分的联系,一个聚合中可以包含多个实体和值对象,因此聚合也被称为根实体。聚合是持久化的基本单位,它和资源库具有一一对应的关系。在聚合中,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根以外的其他 Entity 都有本地表示,但这些标识只有在聚合内部才需要加以区别,因为外部对象除了根 Entity 之外看不到其他对象。在一个聚合中直接引用另外一个聚合并不是 DDD 所鼓励的,但是我们可以通过 ID 的方式引用另外的聚合,聚合是一个事务的边界。如果一次业务操作涉及到了对多个聚合状态的更改,那么应该采用发布领域事件(参考下文)的方式通知相应的聚合。此时的数据一致性便从事务一致性变成了最终一致性(Eventual Consistency)。
  • 领域服务(Domain Service) 建模一个领域概念,把它放在实体上不合适,它放在值对象上也不合适,或者碰到跨聚合实例业务逻辑,没办法合理放到某个实体中的业务逻辑,领域服务就是应对这些情况的服务。如果勉强地把这些重要的领域功能归为 Entity 或 Value Object 的职责,那么不是歪曲了基于模型的对象的定义,就是人为地增加了一些无意义的对象;领域服务和上文中提到的应用服务是不同的,领域服务是领域模型的一部分,而应用服务不是。应用服务是领域服务的客户,它将领域模型变成对外界可用的软件系统。如果将太多的领域逻辑放在领域服务上,实体和值对象上的业务逻辑会越来越弱,将变成贫血对象。在分层架构中要区分什么时候应该定义领域服务,什么时候应该定义应用服务,一个根本的判断依据是看需要封装的职责是否与领域相关。
  • 资源库(Repository) 资源库用于保存和获取聚合对象,将实际的存储和查询技术封装起来,对外隐藏封装了数据访问机制。只为那些确实需要直接访问的聚合提供 Repository。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给 Repository 来完成。资源库与 DAO 有些相似,但也存在显著区别,DAO 是比 Repository 更低的一层,同时 DAO 只是对数据库的一层很薄的封装,而资源库则更加具有领域特征,以“领域”为中心,所描述的是“领域语言”。另外,所有的实体都可以有相应的 DAO,但并不是所有的实体都有资源库,只有聚合才有相应的资源库。
  • 领域事件(Repository) 在 Eric 的《领域驱动设计》中并没有提到领域事件,领域事件是最近几年才加入 DDD 生态系统的。在传统的软件系统中,对数据一致性的处理都是通过事务完成的,其中包括本地事务和全局事务。DDD 的一个重要原则便是一次事务只能更新一个聚合实例,但存在一个业务流程涉及修改多个聚合的事务,怎么实现整个业务流程的数据一致性呢?在 DDD 中,领域事件便可以用于处理上述问题,此时最终一致性取代了事务一致性,通过领域事件的方式达到各个组件之间的数据一致性。既然是领域事件,他们便应该从领域模型中发布,一个领域事件是指一个在领域中“有意义”的事件。领域事件的最终接收者可以是本限界上下文中的组件,也可以是另一个限界上下文。再进一步发展,事件驱动架构可以演变成事件源(Event Sourcing),即对聚合的获取并不是通过加载数据库中的瞬时状态,而是通过重放发生在聚合生命周期中的所有领域事件完成。
  • 工厂(Factories) 当创建一个对象或创建整个聚合时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用 Factory 进行封装,应该将创建复杂对象的实例和聚合的职责转移到一个单独的对象,这个对象本身在领域模型中可能没有职责,但它仍是领域设计的一部分。
  • 模块(Modules) 可以从两种维度来观察模型,一是可以在 Module 中查看细节,而不会被整个模型淹没;二是观察 Module 之间的关系,而不考虑其内部细节。模块之间应该是低耦合的,而在模块内部则是高内聚的。模块并不仅仅是代码的划分,而且也是概念的划分。找到一种低耦合的概念组织方式,从而可以相互独立地理解和分析这些概念。对模型进行精化,直到可以根据高层领域概念对模型进行划分,同时相应的代码也不会产生耦合。

模型关系

20220220224335

对象概念

VO(View Object):视图对象,用于展示层,它的作用是把某个指定页面(或组件)的所有数据封装起来。DTO(Data Transfer Object)数据传输对象,分布式应用提供粗粒度的数据实体,也是一种数据传输协议,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载,这里泛指用于展示层与服务层之间的数据传输对象。RPC 对外暴露的服务涉及对象 API 就是 DTO,如 JSF(京东 RPC 框架)、Dubbo。对比 VO:绝大多数应用场景下,VO 与 DTO 的属性值基本一致,但对于设计层面来说,概念上还是存在区别,DTO 代表服务层需要接收的数据和返回的数据,而 VO 代表展示层需要显示的数据。

DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。DO 不是简单的 POJO,它具有领域业务逻辑。PO(Persistent Object):持久化对象。

对比 DO:DO 和 PO 在绝大部分情况下是一一对应的,但也存在区别,例如 DO 在某些场景下不需要进行显式的持久化,只驻留在静态内存。同样 PO 也可以没有对应的 DO,比如一对多表关系在领域模型层面不需要单独的领域对象。

下面是这些对象在系统架构中的分布:

20220220224356

Domain Primitive

Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有丰富行为和业务逻辑的 Value Object,DP 使用业务域中的原生语言,可以是业务域的最小组成部分、也可以构建复杂组合。Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。在项目中,散落在各个服务或工具类里面的代码,都可以抽出来放在 DP 里,成为 DP 自己的行为或属性。原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP。因为 DP 也是一种 Object Value,本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。Domain Primitive 涉及三种手段:

让隐性的概念显性化(Make Implicit Concepts Explicit)通天塔活动类型就是一个简单的 int 类型,属于隐式概念,但活动类型包含了很多相关业务逻辑,比如类型名称,不同类型活动具有独特的 Icon,判断活动类型是否是判断等,我们把活动类型显性化,定义为一个 Value Object。

20220220224414

让隐性的上下文显性化(Make Implicit Context Explicit)当要实现一个功能或进行逻辑判断依赖多个概念时,可以把这些概念封装到一个独立地完整概念,也是一种 Object Value:

20220220224430

封装多对象行为(Encapsulate Multi-Object Behavior)常见推荐使用 Domain Primitive 的场景有:

  • 有格式要求的 String:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等。

  • 限制的 Integer:比如 OrderId(>0),Percentage(0-100%),Quantity(>=0)等。

  • 可枚举的 int:比如 Status(一般不用 Enum 因为反序列化问题)。

  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等。

  • 复杂的数据结构:比如 Map<String, List>等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为,如通天塔的活动 Map 类。

  • 接口变得清晰可读,校验逻辑内聚,在接口边界外完成,无胶水代码,业务逻辑清晰可读,代码变得更容易测试,也更安全。

总结

DDD 不是一套框架,而是一种面向复杂问题的建模方法论和实践,所以在代码层面缺乏了足够的约束,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。

而且 DDD 诸多实践在真正践行时面临很多挑战,

  • 首先是领域专家和技术人员在建模过程中要摒弃自己固有的专业背景和思维定式,专注于问题域,基于统一语言紧密沟通和协作,具有深度业务领域理解和洞察的领域专家和一个精通领域建模和架构设计的技术团队一样少见,都必须经过长时间学习和实践的。
  • 其次技术人员必须转变思维和架构习惯,软件系统最终交付的是业务价值,不是功能和技术方案,一切要以问题和业务为核心去建模和架构。

7.2 - DDD 指导微服务

对于服务拆分的逻辑来说,是先设计高内聚低耦合的领域模型,再实现相应的分布式系统。服务的划分有一些基本的方法和原则,通过这些方法能让微服务划分更有操作性。最终在微服务落地实施时也能按图索骥,无论是对遗留系统改造还是全新系统的架构都能游刃有余。

开发者在刚开始尝试实现自己的微服务架构时,往往会产生一系列问题 :

  1. 微服务到底应该怎么划分?
  2. 一个典型的微服务到底应该有多微?
  3. 如果做了微服务设计,最后真的会有好处吗?

回答上面的问题需要首先了解微服务设计的逻辑,科学的架构设计应该通过一些输入并逐步推导出结果,架构师要避免凭空设计和“拍脑门”的做法。

对于服务拆分的逻辑来说,是先设计高内聚低耦合的领域模型,再实现相应的分布式系统。服务的划分有一些基本的方法和原则,通过这些方法能让微服务划分更有操作性。最终在微服务落地实施时也能按图索骥,无论是对遗留系统改造还是全新系统的架构都能游刃有余。

微服务拆分的几个阶段

在开始划分微服务之前,架构师需要在大脑中有一个重要的认识:微服务只是手段,不是目的。

微服务架构是为了让系统变得更容易拓展、更富有弹性。在把单体应用变成靠谱的微服务架构之前,单体系统的各个模块应该是合理、清晰地。也就是说,从逻辑上单体系统和微服务没有区别,某种理想情况下微服务只是把单体系统的各个模块分开部署了而已(最近流行的monorepo把多个服务的代码仓库以模块的形式组织到了一起,证明了这一点)。

大量的实践教训告诉我们,混沌的微服务架构,比解耦良好的单体应用会带来更多麻烦。

混乱的微服务VS良好的单体

开源社区为此进行了大量讨论,试图对系统解耦寻找一种行之有效的方法,因此具有十几年历史的领域驱动设计(DDD)方法论被重新认识。领域驱动设计立足于面向对象思想,从业务出发,通过领域模型的方式反映系统的抽象,从而得到合理的服务划分。

采用 DDD 来进行业务建模和服务拆分时,可以参考下面几个阶段:

  1. 使用 DDD(领域驱动建模) 进行业务建模,从业务中获取抽象的模型(例如订单、用户),根据模型的关系进行划分限界上下文。
  2. 检验模型是否得到合适的的抽象,并能反映系统设计和响应业务变化。
  3. 从 DDD 的限界上下文往微服务转化,并得到系统架构、API 列表、集成方式等产出。
20220220224923

如何抽象?

抽象需要找到看似无关事物的内在联系,对微服务的设计尤为重要。

假设有一天,你在某电商网站购买了一台空调,当你支付了空调订单的费用后,又让你再次支付安装订单费用,你肯定大为光火。原因仅仅可能是架构师在设计系统时,为空调这种普通产品生产了一个订单,而安装作为了另外业务逻辑生成了单独的订单。

你一定觉得这个例子太傻了,架构师不会这点都没考虑到,”安装“ 应该被抽象成一个产品,而”安装行为“可以作为另外一个服务。然而现实的例子比比皆是,电信或移动营业厅还需要用户分两步办理号卡业务、宽带业务。这是不合适的抽象模型影响了最终的微服务划分。

20220220224942

所以,没有抽象出领域模型,就得不到正确的微服务划分。

使用 DDD 进行业务建模

通过利用 DDD 对系统从业务的角度分析,对系统进行抽象后,得到内聚更高的业务模型集合,在 DDD 中一组概念接近、高度内聚并能找到清晰的边界的业务模型被称作限界上下文(Bounded Context)。

限界上下文可以视为逻辑上的微服务,或者单体应用中的一个组件。在电商领域就是订单、商品以及支付等几个在电商领域最为常见的概念;在社交领域就是用户、群组、消息等。

DDD 的方法论中是如何找到子系统的边界的呢?

其中一项实践叫做事件风暴工作坊,工作坊要求业务需求提出者和技术实施者协作完成领域建模。把系统状态做出改变的事件作为关键点,从系统事件的角度触发,提取能反应系统运作的业务模型。再进一步识别模型之间的关系,划分出限界上下文,可以看做逻辑上的微服务。

事件是系统数据流中的关键点,类似于电影制作中的关键帧。在未建立模型之前,系统就像是一个黑盒,不断的刺探系统的状态的变化就可以识别出某种反应系统变化的实体。

例如系统管理员可以登录、创建商品、上架商品,对应的系统状态的改变是用户已登录、商品已创建、商品已经上架;相应的顾客可以登录、创建订单、支付,对应的系统状态改变是用户已登录、订单已创建、订单已支付。

于是可以通过收集上面的事件了解到,“哦,原来是商品相关事件是对系统中商品状态做出的改变,商品可以表达系统中某一部分,商品可以作为模型”。

20220220225006

在得到模型之后,通过分析模型之间的关系得出限界上下文。例如商品属性和商品相对于用户、用户组关系更为密切,通过这些关系作出限界上下文拆分的基本线索。

其次是识别模型中的二义性,进一步让限界上下文更为准确。

在电商领域,另外一个不恰当设计的例子是:把订单的订单项当做和商品同样的概念划分到了商品服务,当订单需要修改订单下的商品信息时,需要访问商品服务,这势必造成了订单和商品服务的耦合。

合理的设计应该是:商品服务提供商品的信息给订单服务,但是订单服务没有理由修改商品信息,而是访问作为商品快照的订单项。订单项应该作为一个独立的概念被划分到订单服务中,而不是和商品使用同一个概念,甚至共享同一张数据库表。

20220220225024

“地址”和“商品”在不同的系统中实际上表达不同的含义,这就是术语”上下文“的由来。一组关系密切的模型形成了上下文(context),二义性的识别能帮我们找到上下文的边界(bounded)。

当然,在 DDD 中具体识别限界上下文的线索还很多,例如模型的生命周期等,我们会在后面的文章中逐步展开。在后续的文章中,我们会介绍更多关于 DDD 和事件风暴的思想和原理。

验证和评审领域模型

前面我们说到限界上下文可以作为逻辑上的微服务,但并不意味着我们可以直接把限界上下文变成微服务。在这之前很重要的一件事情是对模型进行验证,如果我们得到的限界上下文被抽象的不良好,在微服务实施后并不能得到良好的拓展性和重用。

限界上下文被设计出来后,验证它的方法可以从我们采用微服务的两个目的出发:降低耦合、容易扩展,可以作为限界上下文评审原则:

  1. 原则1:设计出来的限界上下文之间的互相依赖应该越少越好,依赖的上游不应该知道下游的信息。(被依赖者,例如订单依赖商品,商品不需要知道订单的信息)。
  2. 原则2:使用潜在业务进行适配,如果能在一定程度上响应业务变化,则证明用它指导出来的微服务可以在相当一段时间内足以支撑应用开发。
20220220225048

上图是一个电信运营商的领域模型的局部,这部分展示了电信号码资源以及群组、用户、宽带业务、电话业务这几个限界上下文。主要业务逻辑是,系统提供了号码资源,用户在创建时会和号码资源进行绑定写卡操作,最后再开通电话或宽带业务。在开通电话这个业务流程中,号码资源并不需要知道调用者的信息。

但是理想的领域模型往往抽象程度、成本、复用性这几个因素中获取平衡,软件设计往往没有理想的领域模型,大多数情况下都是平衡各种因素的苟且,因此评审领域模型时也要考虑现实的制约。

20220220225101

用一个简单的图来表达话,我们的领域模型设计往往在复用性和成本取得平衡的中间区域才有实用价值。前面电信业务同样的场景,业务专家和架构师表示,我们需要更为高度的抽象来满足未来更多业务的接入,因此对于两个业务来说,我们需要进一步抽象出产品和订单的概念。

但是同时需要注意到,我们最终落地时的微服务会变得更多,也变得更为复杂,当然优势也是很明显的 —— 更多的业务可以接入订单服务,同时订单服务不需要知道接入的具体业务。对于用户的感知来说,可以一次办理多个业务并统一支付了,这正是某电信当前的痛点之一。

20220220225114

几个典型的误区

在大量使用 DDD 指导微服务拆分的实践后,我们发现很多系统设计存在一些常见的误区,主要分为三类:未成功做出抽象、抽象程度过高、错误的抽象。

未成功做出抽象

在实际开发过程中,大家都有一个体会,设计阶段只考虑了一些常见的服务,但是发现项目中有大量可以重用的逻辑,并应该做成单独服务。当我们在做服务拆分时,遗漏了服务的结果是有一些业务逻辑被分散到各个服务中,并不断重复。

以下是一个检查单,帮助你检查项目上常见的抽象是否具备:

  1. 用户
  2. 权限
  3. 订单
  4. 商品
  5. 支付
  6. 账单
  7. 地址
  8. 通知
  9. 报表单
  10. 日志
  11. 收藏
  12. 发票
  13. 财务
  14. 邮件
  15. 短信
  16. 行为分析

错误抽象

**对微服务或 DDD 理解不够。**模型具有二义性,被放到不同的限界上下文。例如,订单中的收货地址、用户配置的常用地址以及地址库中的标准地址。这三种地址虽然名称类似,但是在概念上完全不是一回事,假如架构师将”地址“划分到了标准地址库中,势必会造成用户上下文和系统配置上下文、订单上下文存在不必要的耦合。

20220220225147

上图的右边为正常的依赖关系,左边产生了不正常的依赖,会进一步产生双向依赖。

在系统设计时,领域模型的二义性是一个比较难以识别和理解问题。好在我们可以通过画概念图来梳理这些概念的关系,概念图是中学教辅解释大量概念的惯用手段,在表达系统设计时一样有用。

20220220225201

与地址类似的常见还有商品和订单项中的商品;用户和用户组之间有一个成员的概念;短信的概念应该更为具体到一条具体的短信和短信模板的区别。

组织对架构的干预

另外一种令人感到惊讶的架构问题是企业的组织架构和团队划分影响了领域模型的正确建立。有一些公司按照渠道来划分了团队,甚至按照 To C (面向于用户)和 To B(面向企业内部)划分的团队,最终设计出来的限界上下文中赫然出现 “C 端文章服务”,“B 端文章服务”。

不乏有一些公司因为团队职责的关系,将本应该集中的服务不得已下放给应用或者 BFF(面向前端的backend)。对于这类问题,其实超出了 DDD 能解决的范围,只能说在建模时警惕此类行为对系统造成很严重的影响。

另外企业组织架构和技术架构的关系,请参考康威定律的叙述。一个由无数敏捷团队组成的企业,和微服务有天然的联系;传统实时瀑布模型的企业,在大型软件时代竞争力十足,但是在互联网时代却无力应对变化。

20220220225220

抽象程度过高

抽象程度过高最典型的一个特征是得到的限界上下文极端的微小。回到我们成本、复用性和抽象程度这几个概念上来,上面我们讨论过,抽象程度虽然可以带来复用性的提高,但是带来的成本甚至不可接受。

抽象程度过高带来的成本有:更多的微服务部署带来的运维压力、开发调试难度提高、服务间通信带来的性能开销、跨服务的分布式事务协调等。因此抽象不是越高越好,应根据实际业务需要和成本考虑。

那相应的,微服务到底应该多小呢?

业界流传一句话来形容,微服务应该多小:“一个微服务应该可以在二周内完成重写“。这句话可能只是一句调侃,如果真的作为微服务应该多微的标准是不可取的。

微服务的大小应该取决于划分限界上下文时各个限界上下文内聚程度。订单服务往往是很多 IT系统中最为复杂、内聚程度最高的服务,往往比较庞大,但无法强行分为 “订单part1” “订单part2” 等多个微服务;同样,短信服务可能仅仅负责和外部系统对接,表现的极为简单,我们往往也需要单独部署。

从限界上下文到系统架构

在通过 DDD 得到领域模型和限界上下文后,理论上我们已经得到了微服务的拆分。但是,限界上下文到系统架构还需要完成下面几件事。

设计微服务之间的依赖关系

一个合理的分布式系统,系统之间的依赖应该是非常清晰地。依赖,在软件开发中指的是一个应用或者组件需要另外一个组件提供必要的功能才能正常工作。因此被依赖的组件是不知道依赖它的应用的,换句话说,被调用者不需要知道调用方的信息,否则这不是一个合理的依赖

在微服务设计时,如果 domain service 需要通过一个 from 参数,根据不同的渠道做出不同的行为,这对系统的拓展是致命的。例如,用户服务对于访问他的来源不应该知晓;用户服务应该对订单、商品、物流等访问者提供无差别的服务。

因此,微服务的依赖关系可以总结为:上游系统不需要知道下游系统信息,否则请重新审视系统架构。

设计微服务间集成方式

拆分微服务是为了更好的集成到一起,对于后续落地来说,还有服务集成这一重要的阶段。微服务之间的集成方式会受到很多因素的制约,前面在讨论微服务到底有多微的时候就顺便提到了集成会带来成本,处于不同的目的可以采用不同的集成方式。

  1. 采用 RPC(远程调用) 的方式集成。使用 RPC 的方式可以让开发者非常容易的切换到分布式系统开发中来,但是 RPC 的耦合性依然很高,同时需要对RPC 平台依赖。业界优秀的 RPC 框架有 dubbo、Grpc、thrift 等
  2. 采用消息的方式集成。使用消息的方式则改变的开发的逻辑,服务之间使用发布-订阅的方式交互。另外一种思想是 IT系统不是数据的流动,而是改变系统状态的事件传递,因此产生了 Event Sourcing 这种集成模式,让微服务具备天然的弹性。
  3. 采用 RESTful 方式集成。RESTful 是一种最大化利用 HTTP 协议的 API 设计方式,服务之间通过 HTTP API 集成。这种方式让耦合变得极低,甚至稍作修改就可以暴露给外部系统使用。

这三种集成方式耦合程度由高到低,适用于不同的场景,需要根据实际情况选择,甚至在系统中可能同时存在。服务间集成的方式还有其他方式,一般来说,上面三种微服务集成的方式可以概括目前常见系统大部分需求。

可视化架构和沉淀输出

第一次读 DDD 相关的资料和书籍时,没有记住 DDD 的很多概念,但是子域划分像极了潮汕牛肉火锅的划分图,给我留下深刻的印象。DDD 强调技术人员和业务人员共同协作,DDD 对图的绘制表现的非常随意自然。

20220220225251

但是在做系统设计时,应该使用更为准确和容易传递的架构图,例如使用 C4 模型中的系统全景图 (System Landscape diagram) 来表达微服务之间的关系。当然你也可以使用 UML 来完成架构设计。C4 只是层次化(架构缩放)方式表达架构设计,和 UML 并不冲突。

系统架构图除了微服务的关系之外,也需要讲技术选型表达出来。

微服务集成方式除了通过架构图标识之外,最好也通过 API 列表的方式将事件风暴中的事件转换为 API;除此之外,可以将 DDD 领域模型细化成聚合根、实体、值对象,请参考 DDD 的战术设计。

总结

大部分人(包括我)缺乏经验,其实更缺乏逻辑。写这篇文章的初衷是为了回答一个问题:如果老板问我,为什么这么划分微服务是可行的,我该怎么有说服力的回复?

我该回答 “具体情况具体分析?By experience?”还是说,我是通过一套方法对业务逻辑进行分析得到的。当没有经验或经验不够多时,能支撑你做出决策就只有对输入问题进行分析。

使用 DDD 指导微服务划分,能在一定程度上弥补经验的不足,做出有理有据的系统架构设计。