Dunwu Blog

大道至简,知易行难

翻译自:https://sourcemaking.com/refactoring/smells/dispensables

非必要的(Dispensables)这组坏味道意味着:这样的代码可有可无,它的存在反而影响整体代码的整洁和可读性。

冗余类

冗余类(Lazy Class)

理解和维护总是费时费力的。如果一个类不值得你花费精力,它就应该被删除。

img

问题原因

也许一个类的初始设计是一个功能完全的类,然而随着代码的变迁,变得没什么用了。
又或者类起初的设计是为了支持未来的功能扩展,然而却一直未派上用场。

解决方法

  • 没什么用的类可以运用 将类内联化(Inline Class) 来干掉。

img

  • 如果子类用处不大,试试 折叠继承体系(Collapse Hierarchy)

收益

  • 减少代码量
  • 易于维护

何时忽略

  • 有时,创建冗余类是为了描述未来开发的意图。在这种情况下,尝试在代码中保持清晰和简单之间的平衡。

重构方法说明

将类内联化(Inline Class)

问题

某个类没有做太多事情。

img

解决

将这个类的所有特性搬移到另一个类中,然后移除原类。

img

折叠继承体系(Collapse Hierarchy)

问题

超类和子类之间无太大区别。

img

解决

将它们合为一体。

img

夸夸其谈未来性

夸夸其谈未来性(Speculative Generality)

存在未被使用的类、函数、字段或参数。

img

问题原因

有时,代码仅仅为了支持未来的特性而产生,然而却一直未实现。结果,代码变得难以理解和维护。

解决方法

  • 如果你的某个抽象类其实没有太大作用,请运用 折叠继承体系(Collapse Hierarch)

img

  • 不必要的委托可运用 将类内联化(Inline Class) 消除。
  • 无用的函数可运用 内联函数(Inline Method) 消除。
  • 函数中有无用的参数应该运用 移除参数(Remove Parameter) 消除。
  • 无用字段可以直接删除。

收益

  • 减少代码量。
  • 更易维护。

何时忽略

  • 如果你在一个框架上工作,创建框架本身没有使用的功能是非常合理的,只要框架的用户需要这个功能。
  • 删除元素之前,请确保它们不在单元测试中使用。如果测试需要从类中获取某些内部信息或执行特殊的测试相关操作,就会发生这种情况。

重构方法说明

折叠继承体系(Collapse Hierarchy)

问题

超类和子类之间无太大区别。

img

解决

将它们合为一体。

img

将类内联化(Inline Class)

问题

某个类没有做太多事情。

img

解决

将这个类的所有特性搬移到另一个类中,然后移除原类。

img

内联函数(Inline Method)

问题

一个函数的本体比函数名更清楚易懂。

1
2
3
4
5
6
7
8
9
class PizzaDelivery {
//...
int getRating() {
return moreThanFiveLateDeliveries() ? 2 : 1;
}
boolean moreThanFiveLateDeliveries() {
return numberOfLateDeliveries > 5;
}
}

解决

在函数调用点插入函数本体,然后移除该函数。

1
2
3
4
5
6
class PizzaDelivery {
//...
int getRating() {
return numberOfLateDeliveries > 5 ? 2 : 1;
}
}

移除参数(Remove Parameter)

问题

函数本体不再需要某个参数。

img

解决

将该参数去除。

img

纯稚的数据类

纯稚的数据类(Data Class) 指的是只包含字段和访问它们的 getter 和 setter 函数的类。这些仅仅是供其他类使用的数据容器。这些类不包含任何附加功能,并且不能对自己拥有的数据进行独立操作。

img

问题原因

当一个新创建的类只包含几个公共字段(甚至可能几个 getters / setters)是很正常的。但是对象的真正力量在于它们可以包含作用于数据的行为类型或操作。

解决方法

  • 如果一个类有公共字段,你应该运用 封装字段(Encapsulated Field) 来隐藏字段的直接访问方式。
  • 如果这些类含容器类的字段,你应该检查它们是不是得到了恰当的封装;如果没有,就运用 封装集合(Encapsulated Collection) 把它们封装起来。
  • 找出这些 getter/setter 函数被其他类运用的地点。尝试以 搬移函数(Move Method) 把那些调用行为搬移到 纯稚的数据类(Data Class) 来。如果无法搬移这个函数,就运用 提炼函数(Extract Method) 产生一个可搬移的函数。

img

  • 在类已经充满了深思熟虑的函数之后,你可能想要摆脱旧的数据访问方法,以提供适应面较广的类数据访问接口。为此,可以运用 移除设置函数(Remove Setting Method)隐藏函数(Hide Method)

收益

  • 提高代码的可读性和组织性。特定数据的操作现在被集中在一个地方,而不是在分散在代码各处。
  • 帮助你发现客户端代码的重复处。

重构方法说明

封装字段(Encapsulated Field)

问题

你的类中存在 public 字段。

1
2
3
class Person {
public String name;
}

解决

将它声明为 private,并提供相应的访问函数。

1
2
3
4
5
6
7
8
9
10
class Person {
private String name;

public String getName() {
return name;
}
public void setName(String arg) {
name = arg;
}
}

封装集合(Encapsulated Collection)

问题

有个函数返回一个集合。

img

解决

让该函数返回该集合的一个只读副本,并在这个类中提供添加、移除集合元素的函数。

img

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

移除设置函数(Remove Setting Method)

问题

类中的某个字段应该在对象创建时被设值,然后就不再改变。

img

解决

去掉该字段的所有设值函数。

img

隐藏函数(Hide Method)

问题

有一个函数,从来没有被其他任何类用到。

img

解决

将这个函数修改为 private。

img

过多的注释

过多的注释(Comments)

注释本身并不是坏事。但是常常有这样的情况:一段代码中出现长长的注释,而它之所以存在,是因为代码很糟糕。

img

问题原因

注释的作者意识到自己的代码不直观或不明显,所以想使用注释来说明自己的意图。这种情况下,注释就像是烂代码的除臭剂。

最好的注释是为函数或类起一个恰当的名字。

如果你觉得一个代码片段没有注释就无法理解,请先尝试重构,试着让所有注释都变得多余。

解决方法

  • 如果一个注释是为了解释一个复杂的表达式,可以运用 提炼变量(Extract Variable) 将表达式切分为易理解的子表达式。
  • 如果你需要通过注释来解释一段代码做了什么,请试试 提炼函数(Extract Method)
  • 如果函数已经被提炼,但仍需要注释函数做了什么,试试运用 函数改名(Rename Method) 来为函数起一个可以自解释的名字。
  • 如果需要对系统某状态进行断言,请运用 引入断言(Introduce Assertion)

收益

  • 代码变得更直观和明显。

何时忽略

注释有时候很有用:

  • 当解释为什么某事物要以特殊方式实现时。
  • 当解释某种复杂算法时。
  • 当你实在不知可以做些什么时。

重构方法说明

提炼变量(Extract Variable)

问题

你有个难以理解的表达式。

1
2
3
4
5
6
7
8
void renderBanner() {
if ((platform.toUpperCase().indexOf("MAC") > -1) &&
(browser.toUpperCase().indexOf("IE") > -1) &&
wasInitialized() && resize > 0 )
{
// do something
}
}

解决

将表达式的结果或它的子表达式的结果用不言自明的变量来替代。

1
2
3
4
5
6
7
8
9
void renderBanner() {
final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
final boolean isIE = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;

if (isMacOs && isIE && wasInitialized() && wasResized) {
// do something
}
}

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

函数改名(Rename Method)

问题

函数的名称未能恰当的揭示函数的用途。

1
2
3
class Person {
public String getsnm();
}

解决

修改函数名。

1
2
3
class Person {
public String getSecondName();
}

引入断言(Introduce Assertion)

问题

某一段代码需要对程序状态做出某种假设。

1
2
3
4
5
6
double getExpenseLimit() {
// should have either expense limit or a primary project
return (expenseLimit != NULL_EXPENSE) ?
expenseLimit:
primaryProject.getMemberExpenseLimit();
}

解决

以断言明确表现这种假设。

1
2
3
4
5
6
7
double getExpenseLimit() {
Assert.isTrue(expenseLimit != NULL_EXPENSE || primaryProject != null);

return (expenseLimit != NULL_EXPENSE) ?
expenseLimit:
primaryProject.getMemberExpenseLimit();
}

注:请不要滥用断言。不要使用它来检查”应该为真“的条件,只能使用它来检查“一定必须为真”的条件。实际上,断言更多是用于自我检测代码的一种手段。在产品真正交付时,往往都会消除所有断言。

重复代码

重复代码(Duplicate Code)

重复代码堪称为代码坏味道之首。消除重复代码总是有利无害的。

img

问题原因

重复代码通常发生在多个程序员同时在同一程序的不同部分上工作时。由于他们正在处理不同的任务,他们可能不知道他们的同事已经写了类似的代码。

还有一种更隐晦的重复,特定部分的代码看上去不同但实际在做同一件事。这种重复代码往往难以找到和消除。

有时重复是有目的性的。当急于满足 deadline,并且现有代码对于要交付的任务是“几乎正确的”时,新手程序员可能无法抵抗复制和粘贴相关代码的诱惑。在某些情况下,程序员只是太懒惰。

解决方法

  • 同一个类的两个函数含有相同的表达式,这时可以采用 提炼函数(Extract Method) 提炼出重复的代码,然后让这两个地点都调用被提炼出来的那段代码。

img

  • 如果两个互为兄弟的子类含有重复代码:
    • 首先对两个类都运用 提炼函数(Extract Method) ,然后对被提炼出来的函数运用 函数上移(Pull Up Method) ,将它推入超类。
    • 如果重复代码在构造函数中,运用 构造函数本体上移(Pull Up Constructor Body)
    • 如果重复代码只是相似但不是完全相同,运用 塑造模板函数(Form Template Method) 获得一个 模板方法模式(Template Method)
    • 如果有些函数以不同的算法做相同的事,你可以选择其中较清晰地一个,并运用 替换算法(Substitute Algorithm) 将其他函数的算法替换掉。
  • 如果两个毫不相关的类中有重复代码:
    • 请尝试运用 提炼超类(Extract Superclass) ,以便为维护所有先前功能的这些类创建一个超类。
    • 如果创建超类十分困难,可以在一个类中运用 提炼类(Extract Class) ,并在另一个类中使用这个新的组件。
  • 如果存在大量的条件表达式,并且它们执行完全相同的代码(仅仅是它们的条件不同),可以运用 合并条件表达式(Consolidate Conditional Expression) 将这些操作合并为单个条件,并运用 提炼函数(Extract Method) 将该条件放入一个名字容易理解的独立函数中。
  • 如果条件表达式的所有分支都有部分相同的代码片段:可以运用 合并重复的条件片段(Consolidate Duplicate Conditional Fragments) 将它们都存在的代码片段置于条件表达式外部。

收益

  • 合并重复代码会简化代码的结构,并减少代码量。
  • 代码更简化、更易维护。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

函数上移(Pull Up Method)

问题

有些函数,在各个子类中产生完全相同的结果。

img

解决

将该函数移至超类。

img

构造函数本体上移(Pull Up Constructor Body)

问题

你在各个子类中拥有一些构造函数,它们的本体几乎完全一致。

1
2
3
4
5
6
7
8
class Manager extends Employee {
public Manager(String name, String id, int grade) {
this.name = name;
this.id = id;
this.grade = grade;
}
//...
}

解决

在超类中新建一个构造函数,并在子类构造函数中调用它。

1
2
3
4
5
6
7
class Manager extends Employee {
public Manager(String name, String id, int grade) {
super(name, id);
this.grade = grade;
}
//...
}

塑造模板函数(Form Template Method)

问题

你有一些子类,其中相应的某些函数以相同的顺序执行类似的操作,但各个操作的细节上有所不同。

img

解决

将这些操作分别放进独立函数中,并保持它们都有相同的签名,于是原函数也就变得相同了。然后将原函数上移至超类。

img

注:这里只提到具体做法,建议了解一下模板方法设计模式。

替换算法(Substitute Algorithm)

问题

你想要把某个算法替换为另一个更清晰的算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String foundPerson(String[] people){
for (int i = 0; i < people.length; i++) {
if (people[i].equals("Don")){
return "Don";
}
if (people[i].equals("John")){
return "John";
}
if (people[i].equals("Kent")){
return "Kent";
}
}
return "";
}

解决

将函数本体替换为另一个算法。

1
2
3
4
5
6
7
8
9
10
String foundPerson(String[] people){
List candidates =
Arrays.asList(new String[] {"Don", "John", "Kent"});
for (int i=0; i < people.length; i++) {
if (candidates.contains(people[i])) {
return people[i];
}
}
return "";
}

提炼超类(Extract Superclass)

问题

两个类有相似特性。

img

解决

为这两个类建立一个超类,将相同特性移至超类。

img

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

合并条件表达式(Consolidate Conditional Expression)

问题

你有一系列条件分支,都得到相同结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
double disabilityAmount() {
if (seniority < 2) {
return 0;
}
if (monthsDisabled > 12) {
return 0;
}
if (isPartTime) {
return 0;
}
// compute the disability amount
//...
}

解决

将这些条件分支合并为一个条件,并将这个条件提炼为一个独立函数。

1
2
3
4
5
6
7
double disabilityAmount() {
if (isNotEligableForDisability()) {
return 0;
}
// compute the disability amount
//...
}

合并重复的条件片段(Consolidate Duplicate Conditional Fragments)

问题

在条件表达式的每个分支上有着相同的一段代码。

1
2
3
4
5
6
7
8
if (isSpecialDeal()) {
total = price * 0.95;
send();
}
else {
total = price * 0.98;
send();
}

解决

将这段重复代码搬移到条件表达式之外。

1
2
3
4
5
6
7
if (isSpecialDeal()) {
total = price * 0.95;
}
else {
total = price * 0.98;
}
send();

扩展阅读

参考资料

翻译自:https://sourcemaking.com/refactoring/smells/couplers

耦合(Couplers)这组坏味道意味着:不同类之间过度耦合。

不完美的库类

不完美的库类(Incomplete Library Class)

当一个类库已经不能满足实际需要时,你就不得不改变这个库(如果这个库是只读的,那就没辙了)。

问题原因

许多编程技术都建立在库类的基础上。库类的作者没用未卜先知的能力,不能因此责怪他们。麻烦的是库往往构造的不够好,而且往往不可能让我们修改其中的类以满足我们的需要。

解决方法

  • 如果你只想修改类库的一两个函数,可以运用 引入外加函数(Introduce Foreign Method)
  • 如果想要添加一大堆额外行为,就得运用 引入本地扩展(Introduce Local Extension)

收益

  • 减少代码重复(你不用一言不合就自己动手实现一个库的全部功能,代价太高)

何时忽略

  • 如果扩展库会带来额外的工作量。

重构方法说明

引入外加函数(Introduce Foreign Method)

问题

你需要为提供服务的类增加一个函数,但你无法修改这个类。

1
2
3
4
5
6
7
8
class Report {
//...
void sendReport() {
Date nextDay = new Date(previousEnd.getYear(),
previousEnd.getMonth(), previousEnd.getDate() + 1);
//...
}
}

解决

在客户类中建立一个函数,并一个第一个参数形式传入一个服务类实例。

1
2
3
4
5
6
7
8
9
10
class Report {
//...
void sendReport() {
Date newStart = nextDay(previousEnd);
//...
}
private static Date nextDay(Date arg) {
return new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
}
}

引入本地扩展(Introduce Local Extension)

问题

你需要为服务类提供一些额外函数,但你无法修改这个类。

img

解决

建立一个新类,使它包含这些额外函数,让这个扩展品成为源类的子类或包装类。

img

中间人

中间人(Middle Man)

如果一个类的作用仅仅是指向另一个类的委托,为什么要存在呢?

img

问题原因

对象的基本特征之一就是封装:对外部世界隐藏其内部细节。封装往往伴随委托。但是人们可能过度运用委托。比如,你也许会看到一个类的大部分有用工作都委托给了其他类,类本身成了一个空壳,除了委托之外不做任何事情。

解决方法

应该运用 移除中间人(Remove Middle Man),直接和真正负责的对象打交道。

收益

  • 减少笨重的代码。

img

何时忽略

如果是以下情况,不要删除已创建的中间人:

  • 添加中间人是为了避免类之间依赖关系。
  • 一些设计模式有目的地创建中间人(例如代理模式和装饰器模式)。

重构方法说明

移除中间人(Remove Middle Man)

问题

某个类做了过多的简单委托动作。

img

解决

让客户直接调用委托类。

img

依恋情结

依恋情结(Feature Envy)

一个函数访问其它对象的数据比访问自己的数据更多。

img

问题原因

这种气味可能发生在字段移动到数据类之后。如果是这种情况,你可能想将数据类的操作移动到这个类中。

解决方法

As a basic rule, if things change at the same time, you should keep them in the same place. Usually data and functions that use this data are changed together (although exceptions are possible).

有一个基本原则:同时会发生改变的事情应该被放在同一个地方。通常,数据和使用这些数据的函数是一起改变的。

img

  • 如果一个函数明显应该被移到另一个地方,可运用 搬移函数(Move Method)
  • 如果仅仅是函数的部分代码访问另一个对象的数据,运用 提炼函数(Extract Method) 将这部分代码移到独立的函数中。
  • 如果一个方法使用来自其他几个类的函数,首先确定哪个类包含大多数使用的数据。然后,将该方法与其他数据一起放在此类中。或者,使用 提炼函数(Extract Method) 将方法拆分为几个部分,可以放置在不同类中的不同位置。

收益

  • 减少重复代码(如果数据处理的代码放在中心位置)。
  • 更好的代码组织性(处理数据的函数靠近实际数据)。

img

何时忽略

  • 有时,行为被有意地与保存数据的类分开。这通常的优点是能够动态地改变行为(见策略设计模式,访问者设计模式和其他模式)。

重构方法说明

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

狎昵关系

狎昵关系(Inappropriate Intimacy)

一个类大量使用另一个类的内部字段和方法。

img

问题原因

类和类之间应该尽量少的感知彼此(减少耦合)。这样的类更容易维护和复用。

解决方法

  • 最简单的解决方法是运用 搬移函数(Move Method)搬移字段(Move Field) 来让类之间斩断羁绊。

img

  • 你也可以看看是否能运用 将双向关联改为单向关联(Change Bidirectional Association to Unidirectional) 让其中一个类对另一个说分手。

  • 如果这两个类实在是情比金坚,难分难舍,可以运用 提炼类(Extract Class) 把二者共同点提炼到一个新类中,让它们产生爱的结晶。或者,可以尝试运用 隐藏委托关系(Hide Delegate) 让另一个类来为它们牵线搭桥。

  • 继承往往造成类之间过分紧密,因为子类对超类的了解总是超过后者的主观愿望,如果你觉得该让这个子类自己闯荡,请运用 以委托取代继承(Replace Inheritance with Delegation) 来让超类和子类分家。

收益

  • 提高代码组织性。
  • 提高代码复用性。

img

重构方法说明

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

搬移字段(Move Field)

问题

在你的程序中,某个字段被其所驻类之外的另一个类更多地用到。

img

解决

在目标类新建一个字段,修改源字段的所有用户,令他们改用新字段。

img

将双向关联改为单向关联(Change Bidirectional Association to Unidirectional)

问题

两个类之间有双向关联,但其中一个类如今不再需要另一个类的特性。

img

解决

去除不必要的关联。

img

提炼类(Extract Class)

问题

某个类做了不止一件事。

img

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

img

隐藏委托关系(Hide Delegate)

问题

客户通过一个委托类来调用另一个对象。

img

解决

在服务类上建立客户所需的所有函数,用以隐藏委托关系。

img

以委托取代继承(Replace Inheritance with Delegation)

问题

某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据。

img

解决

在子类中新建一个字段用以保存超类;调整子类函数,令它改而委托超类;然后去掉两者之间的继承关系。

img

过度耦合的消息链

过度耦合的消息链(Message Chains)

消息链的形式类似于:obj.getA().getB().getC()

img

问题原因

如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。实际代码中你看到的可能是一长串 getThis()或一长串临时变量。采取这种方式,意味客户代码将与查找过程中的导航紧密耦合。一旦对象间关系发生任何变化,客户端就不得不做出相应的修改。

解决方法

  • 可以运用 隐藏委托关系(Hide Delegate) 删除一个消息链。

img

  • 有时更好的选择是:先观察消息链最终得到的对象是用来干什么的。看看能否以 提炼函数(Extract Method)把使用该对象的代码提炼到一个独立函数中,再运用 搬移函数(Move Method) 把这个函数推入消息链。

收益

  • 能减少链中类之间的依赖。
  • 能减少代码量。

img

何时忽略

  • 过于侵略性的委托可能会使程序员难以理解功能是如何触发的。

重构方法说明

隐藏委托关系(Hide Delegate)

问题

客户通过一个委托类来调用另一个对象。

img

解决

在服务类上建立客户所需的所有函数,用以隐藏委托关系。

img

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

1
2
3
4
5
6
7
void printOwing() {
printBanner();

//print details
System.out.println("name: " + name);
System.out.println("amount: " + getOutstanding());
}

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

1
2
3
4
5
6
7
8
9
void printOwing() {
printBanner();
printDetails(getOutstanding());
}

void printDetails(double outstanding) {
System.out.println("name: " + name);
System.out.println("amount: " + outstanding);
}

搬移函数(Move Method)

问题

你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

img

解决

在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是旧函数完全移除。

img

扩展阅读

参考资料

Linux 典型运维应用

💡 如果没有特殊说明,本文的案例都是针对 Centos 发行版本。

网络操作

无法访问外网域名

(1)在 hosts 中添加本机实际 IP 和本机实际域名的映射

1
echo "192.168.0.1 hostname" >> /etc/hosts

如果不知道本机域名,使用 hostname 命令查一下;如果不知道本机实际 IP,使用 ifconfig 查一下。

(2)配置信赖的 DNS 服务器

执行 vi /etc/resolv.conf ,添加以下内容:

1
2
nameserver 114.114.114.114
nameserver 8.8.8.8

114.114.114.114 是国内老牌 DNS

8.8.8.8 是 Google DNS

:point_right: 参考:公共 DNS 哪家强

(3)测试一下能否 ping 通 www.baidu.com

配置网卡

使用 root 权限编辑 /etc/sysconfig/network-scripts/ifcfg-eno16777736X 文件

参考以下进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TYPE=Ethernet                        # 网络类型:Ethernet以太网
BOOTPROTO=none # 引导协议:自动获取、static静态、none不指定
DEFROUTE=yes # 启动默认路由
IPV4_FAILURE_FATAL=no # 不启用IPV4错误检测功能
IPV6INIT=yes # 启用IPV6协议
IPV6_AUTOCONF=yes # 自动配置IPV6地址
IPV6_DEFROUTE=yes # 启用IPV6默认路由
IPV6_FAILURE_FATAL=no # 不启用IPV6错误检测功能
IPV6_PEERDNS=yes
IPV6_PEERROUTES=yes
IPV6_PRIVACY="no"

NAME=eno16777736 # 网卡设备的别名(需要和文件名同名)
UUID=90528772-9967-46da-b401-f82b64b4acbc # 网卡设备的UUID唯一标识号
DEVICE=eno16777736 # 网卡的设备名称
ONBOOT=yes # 开机自动激活网卡
IPADDR=192.168.1.199 # 网卡的固定IP地址
PREFIX=24 # 子网掩码
GATEWAY=192.168.1.1 # 默认网关IP地址
DNS1=8.8.8.8 # DNS域名解析服务器的IP地址

修改完后,执行 systemctl restart network.service 重启网卡服务。

自动化脚本

Linux 开机自启动脚本

(1)在 /etc/rc.local 文件中添加命令

如果不想将脚本粘来粘去,或创建链接,可以在 /etc/rc.local 文件中添加启动命令

  1. 先修改好脚本,使其所有模块都能在任意目录启动时正常执行;
  2. 再在 /etc/rc.local 的末尾添加一行以绝对路径启动脚本的行;

例:

执行 vim /etc/rc.local 命令,输入以下内容:

1
2
3
4
5
6
7
8
#!/bin/sh
#
# This script will be executed *after* all the other init scripts.
# You can put your own initialization stuff in here if you don't
# want to do the full Sys V style init stuff.

touch /var/lock/subsys/local
/opt/pjt_test/test.pl

(2)在 /etc/rc.d/init.d 目录下添加自启动脚本

Linux 在 /etc/rc.d/init.d 下有很多的文件,每个文件都是可以看到内容的,其实都是一些 shell 脚本或者可执行二进制文件。

Linux 开机的时候,会加载运行 /etc/rc.d/init.d 目录下的程序,因此我们可以把想要自动运行的脚本放到这个目录下即可。系统服务的启动就是通过这种方式实现的。

(3)运行级别设置

简单的说,运行级就是操作系统当前正在运行的功能级别。

1
2
3
4
5
6
7
8
不同的运行级定义如下:
# 0 - 停机(千万不能把initdefault 设置为0 )
# 1 - 单用户模式   进入方法#init s = init 1
# 2 - 多用户,没有 NFS
# 3 - 完全多用户模式(标准的运行级)
# 4 - 没有用到
# 5 - X11 多用户图形模式(xwindow)
# 6 - 重新启动 (千万不要把initdefault 设置为6 )

这些级别在 /etc/inittab 文件里指定,这个文件是 init 程序寻找的主要文件,最先运行的服务是放在/etc/rc.d 目录下的文件。

/etc 目录下面有这么几个目录值得注意:rcS.d rc0.d rc1.d … rc6.d (0,1… 6 代表启动级别 0 代表停止,1 代表单用户模式,2-5 代表多用户模式,6 代表重启) 它们的作用就相当于 redhat 下的 rc.d ,你可以把脚本放到 rcS.d,然后修改文件名,给它一个启动序号,如: S88mysql

不过,最好的办法是放到相应的启动级别下面。具体作法:

(1)先把脚本 mysql 放到 /etc/init.d 目录下

(2)查看当前系统的启动级别

1
2
$ runlevel
N 3

(3)设定启动级别

1
2
3
#  98 为启动序号
# 2 是系统的运行级别,可自己调整,注意不要忘了结尾的句点
$ update-rc.d mysql start 98 2 .

现在我们到 /etc/rc2.d 下,就多了一个 S98mysql 这样的符号链接。

(4)重启系统,验证设置是否有效。

(5)移除符号链接

当你需要移除这个符号连接时,方法有三种:

  1. 直接到 /etc/rc2.d 下删掉相应的链接,当然不是最好的方法;

  2. 推荐做法:update-rc.d -f s10 remove

  3. 如果 update-rc.d 命令你不熟悉,还可以试试看 rcconf 这个命令,也很方便。

:point_right: 参考:

定时执行脚本

配置

设置 Linux 启动模式

  1. 停机(记得不要把 initdefault 配置为 0,因为这样会使 Linux 不能启动)
  2. 单用户模式,就像 Win9X 下的安全模式
  3. 多用户,但是没有 NFS
  4. 完全多用户模式,准则的运行级
  5. 通常不用,在一些特殊情况下可以用它来做一些事情
  6. X11,即进到 X-Window 系统
  7. 重新启动 (记得不要把 initdefault 配置为 6,因为这样会使 Linux 不断地重新启动)

设置方法:

1
sed -i 's/id:5:initdefault:/id:3:initdefault:/' /etc/inittab

参考资料

Spring 面试

综合篇

不同版本的 Spring Framework 有哪些主要功能?

Version Feature
Spring 2.5 发布于 2007 年。这是第一个支持注解的版本。
Spring 3.0 发布于 2009 年。它完全利用了 Java5 中的改进,并为 JEE6 提供了支持。
Spring 4.0 发布于 2013 年。这是第一个完全支持 JAVA8 的版本。

什么是 Spring Framework?

  • Spring 是一个开源应用框架,旨在降低应用程序开发的复杂度。
  • 它是轻量级、松散耦合的。
  • 它具有分层体系结构,允许用户选择组件,同时还为 J2EE 应用程序开发提供了一个有凝聚力的框架。
  • 它可以集成其他框架,如 Structs、Hibernate、EJB 等,所以又称为框架的框架。

列举 Spring Framework 的优点。

  • 由于 Spring Frameworks 的分层架构,用户可以自由选择自己需要的组件。
  • Spring Framework 支持 POJO(Plain Old Java Object) 编程,从而具备持续集成和可测试性。
  • 由于依赖注入和控制反转,JDBC 得以简化。
  • 它是开源免费的。

Spring Framework 有哪些不同的功能?

  • 轻量级 - Spring 在代码量和透明度方面都很轻便。
  • IOC - 控制反转
  • AOP - 面向切面编程可以将应用业务逻辑和系统服务分离,以实现高内聚。
  • 容器 - Spring 负责创建和管理对象(Bean)的生命周期和配置。
  • MVC - 对 web 应用提供了高度可配置性,其他框架的集成也十分方便。
  • 事务管理 - 提供了用于事务管理的通用抽象层。Spring 的事务支持也可用于容器较少的环境。
  • JDBC 异常 - Spring 的 JDBC 抽象层提供了一个异常层次结构,简化了错误处理策略。

Spring Framework 中有多少个模块,它们分别是什么?

img

  • Spring 核心容器 – 该层基本上是 Spring Framework 的核心。它包含以下模块:
    • Spring Core
    • Spring Bean
    • SpEL (Spring Expression Language)
    • Spring Context
  • 数据访问/集成 – 该层提供与数据库交互的支持。它包含以下模块:
    • JDBC (Java DataBase Connectivity)
    • ORM (Object Relational Mapping)
    • OXM (Object XML Mappers)
    • JMS (Java Messaging Service)
    • Transaction
  • Web – 该层提供了创建 Web 应用程序的支持。它包含以下模块:
    • Web
    • Web – Servlet
    • Web – Socket
    • Web – Portlet
  • AOP – 该层支持面向切面编程
  • Instrumentation – 该层为类检测和类加载器实现提供支持。
  • Test – 该层为使用 JUnit 和 TestNG 进行测试提供支持。
  • 几个杂项模块:
    • Messaging – 该模块为 STOMP 提供支持。它还支持注解编程模型,该模型用于从 WebSocket 客户端路由和处理 STOMP 消息。
    • Aspects – 该模块为与 AspectJ 的集成提供支持。

什么是 Spring 配置文件?

Spring 配置文件是 XML 文件。该文件主要包含类信息。它描述了这些类是如何配置以及相互引入的。但是,XML 配置文件冗长且更加干净。如果没有正确规划和编写,那么在大项目中管理变得非常困难。

Spring 应用程序有哪些不同组件?

Spring 应用一般有以下组件:

  • 接口 - 定义功能。
  • Bean 类 - 它包含属性,setter 和 getter 方法,函数等。
  • Spring 面向切面编程(AOP) - 提供面向切面编程的功能。
  • Bean 配置文件 - 包含类的信息以及如何配置它们。
  • 用户程序 - 它使用接口。

使用 Spring 有哪些方式?

使用 Spring 有以下方式:

  • 作为一个成熟的 Spring Web 应用程序。
  • 作为第三方 Web 框架,使用 Spring Frameworks 中间层。
  • 用于远程使用。
  • 作为企业级 Java Bean,它可以包装现有的 POJO(Plain Old Java Objects)。

核心篇

IoC

什么是 IoC?什么是依赖注入?什么是 Spring IoC?

IoC控制反转(Inversion of Control,缩写为 IoC)。IoC 又称为依赖倒置原则(设计模式六大原则之一),它的要点在于:程序要依赖于抽象接口,不要依赖于具体实现。它的作用就是用于降低代码间的耦合度

IoC 的实现方式有两种:

  • 依赖注入(Dependency Injection,简称 DI):不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造器、函数参数等方式传递(或注入)给类使用。
  • 依赖查找(Dependency Lookup):容器中的受控对象通过容器的 API 来查找自己所依赖的资源和协作对象。

Spring IoC 是 IoC 的一种实现。DI 是 Spring IoC 的主要实现原则。

依赖注入有哪些实现方式?

依赖注入有如下方式:

依赖注入方式 配置元数据举例
Setter 方法注入 <proeprty name="user" ref="userBean"/>
构造器注入 <constructor-arg name="user" ref="userBean" />
字段注入 @Autowired User user;
方法注入 @Autowired public void user(User user) { ... }
接口回调注入 class MyBean implements BeanFactoryAware { ... }

构造器注入 VS. setter 注入

构造器注入 setter 注入
没有部分注入 有部分注入
不会覆盖 setter 属性 会覆盖 setter 属性
任意修改都会创建一个新实例 任意修改不会创建一个新实例
适用于设置很多属性 适用于设置少量属性

官方推荐使用构造器注入。

BeanFactory VS. ApplicationContext

在 Spring 中,有两种 IoC 容器:BeanFactoryApplicationContext

  • BeanFactory:**BeanFactory 是 Spring 基础 IoC 容器**。BeanFactory 提供了 Spring 容器的配置框架和基本功能。
  • ApplicationContext:**ApplicationContext 是具备应用特性的 BeanFactory 的子接口**。它还扩展了其他一些接口,以支持更丰富的功能,如:国际化、访问资源、事件机制、更方便的支持 AOP、在 web 应用中指定应用层上下文等。

实际开发中,更推荐使用 ApplicationContext 作为 IoC 容器,因为它的功能远多于 BeanFactory

BeanFactory VS. FactoryBean

BeanFactory 是 Spring 基础 IoC 容器

FactoryBean 是创建 Bean 的一种方式,帮助实现复杂的初始化逻辑。

Spring IoC 启动时做了哪些准备

IoC 配置元信息读取和解析

IoC 容器生命周期管理

Spring 事件发布

国际化

等等

Spring IoC 的实现机制是什么

Spring 中的 IoC 的实现原理就是工厂模式加反射机制。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface Fruit {
public abstract void eat();
}
class Apple implements Fruit {
public void eat(){
System.out.println("Apple");
}
}
class Orange implements Fruit {
public void eat(){
System.out.println("Orange");
}
}
class Factory {
public static Fruit getInstance(String ClassName) {
Fruit f=null;
try {
f=(Fruit)Class.forName(ClassName).newInstance();
} catch (Exception e) {
e.printStackTrace();
}
return f;
}
}
class Client {
public static void main(String[] a) {
Fruit f=Factory.getInstance("io.github.dunwu.spring.Apple");
if(f!=null){
f.eat();
}
}
}

Bean

什么是 Spring Bean

在 Spring 中,构成应用程序主体由 Spring IoC 容器管理的对象称为 Bean。Bean 是由 Spring IoC 容器实例化、装配和管理的对象。 Bean 以及它们之间的依赖关系反映在容器使用的配置元数据中。

Spring IoC 容器本身,并不能识别配置的元数据。为此,要将这些配置信息转为 Spring 能识别的格式——BeanDefinition 对象。

BeanDefinition 是 Spring 中定义 Bean 的配置元信息接口,它包含:

  • Bean 类名
  • Bean 行为配置元素,如:作用域、自动绑定的模式、生命周期回调等
  • 其他 Bean 引用,也可称为合作者(Collaborators)或依赖(Dependencies)
  • 配置设置,如 Bean 属性(Properties)

如何注册 Spring Bean

通过 BeanDefinition 和外部单例对象来注册。

spring 提供了哪些配置方式?

  • 基于 xml 配置

bean 所需的依赖项和服务在 XML 格式的配置文件中指定。这些配置文件通常包含许多 bean 定义和特定于应用程序的配置选项。它们通常以 bean 标签开头。例如:

1
2
3
<bean id="studentbean" class="org.edureka.firstSpring.StudentBean">
<property name="name" value="Edureka"></property>
</bean>
  • 基于注解配置

您可以通过在相关的类,方法或字段声明上使用注解,将 bean 配置为组件类本身,而不是使用 XML 来描述 bean 装配。默认情况下,Spring 容器中未打开注解装配。因此,您需要在使用它之前在 Spring 配置文件中启用它。例如:

1
2
3
4
<beans>
<context:annotation-config/>
<!-- bean definitions go here -->
</beans>
  • 基于 Java API 配置

Spring 的 Java 配置是通过使用 @Bean 和 @Configuration 来实现。

  1. @Bean 注解扮演与 <bean /> 元素相同的角色。
  2. @Configuration 类允许通过简单地调用同一个类中的其他 @Bean 方法来定义 bean 间依赖关系。

例如:

1
2
3
4
5
6
7
@Configuration
public class StudentConfig {
@Bean
public StudentBean myStudent() {
return new StudentBean();
}
}

spring 支持集中 bean scope?

Spring bean 支持 5 种 scope:

  • Singleton - 每个 Spring IoC 容器仅有一个单实例。
  • Prototype - 每次请求都会产生一个新的实例。
  • Request - 每一次 HTTP 请求都会产生一个新的实例,并且该 bean 仅在当前 HTTP 请求内有效。
  • Session - 每一次 HTTP 请求都会产生一个新的 bean,同时该 bean 仅在当前 HTTP session 内有效。
  • Global-session - 类似于标准的 HTTP Session 作用域,不过它仅仅在基于 portlet 的 web 应用中才有意义。Portlet 规范定义了全局 Session 的概念,它被所有构成某个 portlet web 应用的各种不同的 portlet 所共享。在 global session 作用域中定义的 bean 被限定于全局 portlet Session 的生命周期范围内。如果你在 web 中使用 global session 作用域来标识 bean,那么 web 会自动当成 session 类型来使用。

仅当用户使用支持 Web 的 ApplicationContext 时,最后三个才可用。

Spring Bean 的生命周期

spring bean 容器的生命周期如下:

  1. Spring 对 Bean 进行实例化(相当于 new XXX())

  2. Spring 将值和引用注入到 Bean 对应的属性中

  3. 如果 Bean 实现了 BeanNameAware 接口,Spring 将 Bean 的 ID 传递给 setBeanName 方法

    • 作用是通过 Bean 的引用来获得 Bean ID,一般业务中是很少有用到 Bean 的 ID 的
  4. 如果 Bean 实现了 BeanFactoryAware 接口,Spring 将调用 setBeanDactory 方法,并把 BeanFactory 容器实例作为参数传入。

    • 作用是获取 Spring 容器,如 Bean 通过 Spring 容器发布事件等
  5. 如果 Bean 实现了 ApplicationContextAware 接口,Spring 容器将调用 setApplicationContext 方法,把应用上下文作为参数传入

    • 作用与 BeanFactory 类似都是为了获取 Spring 容器,不同的是 Spring 容器在调用 setApplicationContext 方法时会把它自己作为 setApplicationContext 的参数传入,而 Spring 容器在调用 setBeanFactory 前需要使用者自己指定(注入)setBeanFactory 里的参数 BeanFactory
  6. 如果 Bean 实现了 BeanPostProcess 接口,Spring 将调用 postProcessBeforeInitialization 方法

    • 作用是在 Bean 实例创建成功后对其进行增强处理,如对 Bean 进行修改,增加某个功能
  7. 如果 Bean 实现了 InitializingBean 接口,Spring 将调用 afterPropertiesSet 方法,作用与在配置文件中对 Bean 使用 init-method 声明初始化的作用一样,都是在 Bean 的全部属性设置成功后执行的初始化方法。

  8. 如果 Bean 实现了 BeanPostProcess 接口,Spring 将调用 postProcessAfterInitialization 方法

    • postProcessBeforeInitialization 是在 Bean 初始化前执行的,而 postProcessAfterInitialization 是在 Bean 初始化后执行的
  9. 经过以上的工作后,Bean 将一直驻留在应用上下文中给应用使用,直到应用上下文被销毁

  10. 如果 Bean 实现了 DispostbleBean 接口,Spring 将调用它的 destory 方法,作用与在配置文件中对 Bean 使用 destory-method 属性的作用一样,都是在 Bean 实例销毁前执行的方法。

什么是 spring 的内部 bean?

只有将 bean 用作另一个 bean 的属性时,才能将 bean 声明为内部 bean。为了定义 bean,Spring 的基于 XML 的配置元数据在 <property><constructor-arg> 中提供了 <bean> 元素的使用。内部 bean 总是匿名的,它们总是作为原型。

例如,假设我们有一个 Student 类,其中引用了 Person 类。这里我们将只创建一个 Person 类实例并在 Student 中使用它。

Student.java

1
2
3
4
5
6
7
8
9
public class Student {
private Person person;
//Setters and Getters
}
public class Person {
private String name;
private String address;
//Setters and Getters
}

bean.xml

1
2
3
4
5
6
7
8
9
<bean id=“StudentBean" class="com.edureka.Student">
<property name="person">
<!--This is inner bean -->
<bean class="com.edureka.Person">
<property name="name" value=“Scott"></property>
<property name="address" value=“Bangalore"></property>
</bean>
</property>
</bean>

什么是 spring 装配

当 bean 在 Spring 容器中组合在一起时,它被称为装配或 bean 装配。 Spring 容器需要知道需要什么 bean 以及容器应该如何使用依赖注入来将 bean 绑定在一起,同时装配 bean。

自动装配有哪些方式?

Spring 容器能够自动装配 bean。也就是说,可以通过检查 BeanFactory 的内容让 Spring 自动解析 bean 的协作者。

自动装配的不同模式:

  • no - 这是默认设置,表示没有自动装配。应使用显式 bean 引用进行装配。
  • byName - 它根据 bean 的名称注入对象依赖项。它匹配并装配其属性与 XML 文件中由相同名称定义的 bean。
  • byType - 它根据类型注入对象依赖项。如果属性的类型与 XML 文件中的一个 bean 名称匹配,则匹配并装配属性。
  • 构造器 - 它通过调用类的构造器来注入依赖项。它有大量的参数。
  • autodetect - 首先容器尝试通过构造器使用 autowire 装配,如果不能,则尝试通过 byType 自动装配。

自动装配有什么局限?

  • 覆盖的可能性 - 您始终可以使用 <constructor-arg><property> 设置指定依赖项,这将覆盖自动装配。
  • 基本元数据类型 - 简单属性(如原数据类型,字符串和类)无法自动装配。
  • 令人困惑的性质 - 总是喜欢使用明确的装配,因为自动装配不太精确。

AOP

什么是 AOP?

AOP(Aspect-Oriented Programming), 即 面向切面编程, 它与 OOP( Object-Oriented Programming, 面向对象编程) 相辅相成, 提供了与 OOP 不同的抽象软件结构的视角.
在 OOP 中, 我们以类(class)作为我们的基本单元, 而 AOP 中的基本单元是 Aspect(切面)

AOP 中的 Aspect、Advice、Pointcut、JointPoint 和 Advice 参数分别是什么?

img

  • Aspect - Aspect 是一个实现交叉问题的类,例如事务管理。方面可以是配置的普通类,然后在 Spring Bean 配置文件中配置,或者我们可以使用 Spring AspectJ 支持使用 @Aspect 注解将类声明为 Aspect。
  • Advice - Advice 是针对特定 JoinPoint 采取的操作。在编程方面,它们是在应用程序中达到具有匹配切入点的特定 JoinPoint 时执行的方法。您可以将 Advice 视为 Spring 拦截器(Interceptor)或 Servlet 过滤器(filter)。
  • Advice Arguments - 我们可以在 advice 方法中传递参数。我们可以在切入点中使用 args() 表达式来应用于与参数模式匹配的任何方法。如果我们使用它,那么我们需要在确定参数类型的 advice 方法中使用相同的名称。
  • Pointcut - Pointcut 是与 JoinPoint 匹配的正则表达式,用于确定是否需要执行 Advice。 Pointcut 使用与 JoinPoint 匹配的不同类型的表达式。Spring 框架使用 AspectJ Pointcut 表达式语言来确定将应用通知方法的 JoinPoint。
  • JoinPoint - JoinPoint 是应用程序中的特定点,例如方法执行,异常处理,更改对象变量值等。在 Spring AOP 中,JoinPoint 始终是方法的执行器。

什么是通知(Advice)?

特定 JoinPoint 处的 Aspect 所采取的动作称为 Advice。Spring AOP 使用一个 Advice 作为拦截器,在 JoinPoint “周围”维护一系列的拦截器。

有哪些类型的通知(Advice)?

  • Before - 这些类型的 Advice 在 joinpoint 方法之前执行,并使用 @Before 注解标记进行配置。
  • After Returning - 这些类型的 Advice 在连接点方法正常执行后执行,并使用@AfterReturning 注解标记进行配置。
  • After Throwing - 这些类型的 Advice 仅在 joinpoint 方法通过抛出异常退出并使用 @AfterThrowing 注解标记配置时执行。
  • After (finally) - 这些类型的 Advice 在连接点方法之后执行,无论方法退出是正常还是异常返回,并使用 @After 注解标记进行配置。
  • Around - 这些类型的 Advice 在连接点之前和之后执行,并使用 @Around 注解标记进行配置。

指出在 spring aop 中 concern 和 cross-cutting concern 的不同之处。

concern 是我们想要在应用程序的特定模块中定义的行为。它可以定义为我们想要实现的功能。

cross-cutting concern 是一个适用于整个应用的行为,这会影响整个应用程序。例如,日志记录,安全性和数据传输是应用程序几乎每个模块都需要关注的问题,因此它们是跨领域的问题。

AOP 有哪些实现方式?

实现 AOP 的技术,主要分为两大类:

  • 静态代理 - 指使用 AOP 框架提供的命令进行编译,从而在编译阶段就可生成 AOP 代理类,因此也称为编译时增强;
    • 编译时编织(特殊编译器实现)
    • 类加载时编织(特殊的类加载器实现)。
  • 动态代理 - 在运行时在内存中“临时”生成 AOP 动态代理类,因此也被称为运行时增强。
    • JDK 动态代理
    • CGLIB

Spring AOP and AspectJ AOP 有什么区别?

Spring AOP 基于动态代理方式实现;AspectJ 基于静态代理方式实现。
Spring AOP 仅支持方法级别的 PointCut;提供了完全的 AOP 支持,它还支持属性级别的 PointCut。

如何理解 Spring 中的代理?

将 Advice 应用于目标对象后创建的对象称为代理。在客户端对象的情况下,目标对象和代理对象是相同的。

1
Advice + Target Object = Proxy

什么是编织(Weaving)?

为了创建一个 advice 对象而链接一个 aspect 和其它应用类型或对象,称为编织(Weaving)。在 Spring AOP 中,编织在运行时执行。请参考下图:

img

注解

你用过哪些重要的 Spring 注解?

  • @Controller - 用于 Spring MVC 项目中的控制器类。
  • @Service - 用于服务类。
  • @RequestMapping - 用于在控制器处理程序方法中配置 URI 映射。
  • @ResponseBody - 用于发送 Object 作为响应,通常用于发送 XML 或 JSON 数据作为响应。
  • @PathVariable - 用于将动态值从 URI 映射到处理程序方法参数。
  • @Autowired - 用于在 spring bean 中自动装配依赖项。
  • @Qualifier - 使用 @Autowired 注解,以避免在存在多个 bean 类型实例时出现混淆。
  • @Scope - 用于配置 spring bean 的范围。
  • @Configuration,**@ComponentScan** 和 @Bean - 用于基于 java 的配置。
  • @Aspect,**@Before@After@Around@Pointcut** - 用于切面编程(AOP)。

如何在 spring 中启动注解装配?

默认情况下,Spring 容器中未打开注解装配。因此,要使用基于注解装配,我们必须通过配置<context:annotation-config /> 元素在 Spring 配置文件中启用它。

@Component, @Controller, @Repository, @Service 有何区别?

  • @Component:这将 java 类标记为 bean。它是任何 Spring 管理组件的通用构造型。spring 的组件扫描机制现在可以将其拾取并将其拉入应用程序环境中。
  • @Controller:这将一个类标记为 Spring Web MVC 控制器。标有它的 Bean 会自动导入到 IoC 容器中。
  • @Service:此注解是组件注解的特化。它不会对 @Component 注解提供任何其他行为。您可以在服务层类中使用 @Service 而不是 @Component,因为它以更好的方式指定了意图。
  • @Repository:这个注解是具有类似用途和功能的 @Component 注解的特化。它为 DAO 提供了额外的好处。它将 DAO 导入 IoC 容器,并使未经检查的异常有资格转换为 Spring DataAccessException。

@Required 注解有什么用?

@Required 应用于 bean 属性 setter 方法。此注解仅指示必须在配置时使用 bean 定义中的显式属性值或使用自动装配填充受影响的 bean 属性。如果尚未填充受影响的 bean 属性,则容器将抛出 BeanInitializationException。

示例:

1
2
3
4
5
6
7
8
9
10
public class Employee {
private String name;
@Required
public void setName(String name){
this.name=name;
}
public string getName(){
return name;
}
}

@Autowired 注解有什么用?

@Autowired 可以更准确地控制应该在何处以及如何进行自动装配。此注解用于在 setter 方法,构造器,具有任意名称或多个参数的属性或方法上自动装配 bean。默认情况下,它是类型驱动的注入。

1
2
3
4
5
6
7
8
9
10
public class Employee {
private String name;
@Autowired
public void setName(String name) {
this.name=name;
}
public string getName(){
return name;
}
}

@Qualifier 注解有什么用?

当您创建多个相同类型的 bean 并希望仅使用属性装配其中一个 bean 时,您可以使用@Qualifier 注解和 @Autowired 通过指定应该装配哪个确切的 bean 来消除歧义。

例如,这里我们分别有两个类,Employee 和 EmpAccount。在 EmpAccount 中,使用@Qualifier 指定了必须装配 id 为 emp1 的 bean。

Employee.java

1
2
3
4
5
6
7
8
9
10
public class Employee {
private String name;
@Autowired
public void setName(String name) {
this.name=name;
}
public string getName() {
return name;
}
}

EmpAccount.java

1
2
3
4
5
6
7
8
9
public class EmpAccount {
private Employee emp;

@Autowired
@Qualifier(emp1)
public void showName() {
System.out.println(“Employee name : ”+emp.getName);
}
}

@RequestMapping 注解有什么用?

@RequestMapping 注解用于将特定 HTTP 请求方法映射到将处理相应请求的控制器中的特定类/方法。此注解可应用于两个级别:

  • 类级别:映射请求的 URL
  • 方法级别:映射 URL 以及 HTTP 请求方法

数据篇

spring DAO 有什么用?

Spring DAO 使得 JDBC,Hibernate 或 JDO 这样的数据访问技术更容易以一种统一的方式工作。这使得用户容易在持久性技术之间切换。它还允许您在编写代码时,无需考虑捕获每种技术不同的异常。

列举 Spring DAO 抛出的异常。

img

spring JDBC API 中存在哪些类?

  • JdbcTemplate
  • SimpleJdbcTemplate
  • NamedParameterJdbcTemplate
  • SimpleJdbcInsert
  • SimpleJdbcCall

使用 Spring 访问 Hibernate 的方法有哪些?

我们可以通过两种方式使用 Spring 访问 Hibernate:

  1. 使用 Hibernate 模板和回调进行控制反转
  2. 扩展 HibernateDAOSupport 并应用 AOP 拦截器节点

列举 spring 支持的事务管理类型

Spring 支持两种类型的事务管理:

  1. 程序化事务管理:在此过程中,在编程的帮助下管理事务。它为您提供极大的灵活性,但维护起来非常困难。
  2. 声明式事务管理:在此,事务管理与业务代码分离。仅使用注解或基于 XML 的配置来管理事务。

spring 支持哪些 ORM 框架

  • Hibernate
  • iBatis
  • JPA
  • JDO
  • OJB

MVC

Spring MVC 框架有什么用?

Spring Web MVC 框架提供 模型-视图-控制器 架构和随时可用的组件,用于开发灵活且松散耦合的 Web 应用程序。 MVC 模式有助于分离应用程序的不同方面,如输入逻辑,业务逻辑和 UI 逻辑,同时在所有这些元素之间提供松散耦合。

描述一下 DispatcherServlet 的工作流程

DispatcherServlet 的工作流程可以用一幅图来说明:

img

  1. 向服务器发送 HTTP 请求,请求被前端控制器 DispatcherServlet 捕获。
  2. DispatcherServlet 根据 <servlet-name>-servlet.xml 中的配置对请求的 URL 进行解析,得到请求资源标识符(URI)。然后根据该 URI,调用 HandlerMapping 获得该 Handler 配置的所有相关的对象(包括 Handler 对象以及 Handler 对象对应的拦截器),最后以HandlerExecutionChain 对象的形式返回。
  3. DispatcherServlet 根据获得的Handler,选择一个合适的 HandlerAdapter。(附注:如果成功获得HandlerAdapter后,此时将开始执行拦截器的 preHandler(…)方法)。
  4. 提取Request中的模型数据,填充Handler入参,开始执行HandlerController)。 在填充Handler的入参过程中,根据你的配置,Spring 将帮你做一些额外的工作:
    • HttpMessageConveter: 将请求消息(如 Json、xml 等数据)转换成一个对象,将对象转换为指定的响应信息。
    • 数据转换:对请求消息进行数据转换。如String转换成IntegerDouble等。
    • 数据根式化:对请求消息进行数据格式化。 如将字符串转换成格式化数字或格式化日期等。
    • 数据验证: 验证数据的有效性(长度、格式等),验证结果存储到BindingResultError中。
  5. Handler(Controller)执行完成后,向 DispatcherServlet 返回一个 ModelAndView 对象;
  6. 根据返回的ModelAndView,选择一个适合的 ViewResolver(必须是已经注册到 Spring 容器中的ViewResolver)返回给DispatcherServlet
  7. ViewResolver 结合ModelView,来渲染视图。
  8. 视图负责将渲染结果返回给客户端。

介绍一下 WebApplicationContext

WebApplicationContext 是 ApplicationContext 的扩展。它具有 Web 应用程序所需的一些额外功能。它与普通的 ApplicationContext 在解析主题和决定与哪个 servlet 关联的能力方面有所不同。

(完)


:point_right: 想学习更多 Spring 内容可以访问我的 Spring 教程:**spring-notes**

资料

大型系统核心技术

大型系统的设计目标就是为了快速、高效、稳定的处理海量的数据以及高并发的请求。

单机服务受限于硬件,客观存在着资源瓶颈,难以应对不断增长的数据量和请求量,为了打破瓶颈,大型系统基本上都被设计为分布式系统。

分布式系统由于其面临的共性问题,在很多场景下的解决方案往往也存在着共性。因此,我们会发现,很多优秀的大型系统在设计方案上存在着很多的共同点。

本文主要讨论应对分布式系统共性问题的解决方案,这既可以加深对分布式系统运作原理的理解,也可以作为设计大型分布式系统时的借鉴。

分布式事务

分布式锁

Java 原生 API 虽然有并发锁,但并没有提供分布式锁的能力,所以针对分布式场景中的锁需要解决的方案。

分布式锁的解决方案大致有以下几种:

  • 基于数据库实现
  • 基于缓存(redis,memcached 等)实现
  • 基于 Zookeeper 实现

基于数据库实现分布式锁

实现

创建表
1
2
3
4
5
6
7
8
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
获取锁

想要锁住某个方法时,执行以下 SQL:

1
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

因为我们对 method_name 做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

成功插入则获取锁。

释放锁

当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:

1
delete from methodLock where method_name ='method_name'

问题

  1. 这把锁强依赖数据库的可用性。如果数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决办法

  1. 单点问题可以用多数据库实例,同时塞 N 个表,N/2+1 个成功就任务锁定成功
  2. 写一个定时任务,隔一段时间清除一次过期的数据。
  3. 写一个 while 循环,不断的重试插入,直到成功。
  4. 在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

小结

  • 优点: 直接借助数据库,容易理解。
  • 缺点: 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。操作数据库需要一定的开销,性能问题需要考虑。

基于 Redis 实现分布式锁

相比于用数据库来实现分布式锁,基于缓存实现的分布式锁的性能会更好一些。目前有很多成熟的分布式产品,包括 Redis、memcache、Tair 等。这里以 Redis 举例。

Redis 命令

  • setnx - setnx key val:当且仅当 key 不存在时,set 一个 key 为 val 的字符串,返回 1;若 key 存在,则什么都不做,返回 0。
  • expire - expire key timeout:为 key 设置一个超时时间,单位为 second,超过这个时间锁会自动释放,避免死锁。
  • delete - delete key:删除 key

实现

单点实现步骤:

  1. 获取锁的使用,使用 setnx 加锁,锁的 value 值为一个随机生成的 UUID,再使用 expire 设置一个过期值。
  2. 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  3. 释放锁的时候,通过 UUID 判断是不是该锁,若是该锁,则执行 delete 进行锁释放。

问题

  • 单点问题。如果单机 redis 挂掉了,那么程序会跟着出错。
  • 如果转移使用 slave 节点,复制不是同步复制,会出现多个程序获取锁的情况

小结

可以考虑使用 redisson 的解决方案

基于 ZooKeeper 实现分布式锁

实现

这也是 ZooKeeper 客户端 curator 的分布式锁实现。

  1. 创建一个目录 mylock;
  2. 线程 A 想获取锁就在 mylock 目录下创建临时顺序节点;
  3. 获取 mylock 目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 线程 B 获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
  5. 线程 A 处理完,删除自己的节点,线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

小结

ZooKeeper 版本的分布式锁问题相对比较来说少。

  • 锁的占用时间限制:redis 就有占用时间限制,而 ZooKeeper 则没有,最主要的原因是 redis 目前没有办法知道已经获取锁的客户端的状态,是已经挂了呢还是正在执行耗时较长的业务逻辑。而 ZooKeeper 通过临时节点就能清晰知道,如果临时节点存在说明还在执行业务逻辑,如果临时节点不存在说明已经执行完毕释放锁或者是挂了。由此看来 redis 如果能像 ZooKeeper 一样添加一些与客户端绑定的临时键,也是一大好事。
  • 是否单点故障:redis 本身有很多中玩法,如客户端一致性 hash,服务器端 sentinel 方案或者 cluster 方案,很难做到一种分布式锁方式能应对所有这些方案。而 ZooKeeper 只有一种玩法,多台机器的节点数据是一致的,没有 redis 的那么多的麻烦因素要考虑。

总体上来说 ZooKeeper 实现分布式锁更加的简单,可靠性更高。但 ZooKeeper 因为需要频繁的创建和删除节点,性能上不如 Redis 方式。

分布式 Session

在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。

分布式 Session 的几种实现策略:

  1. 粘性 session
  2. 应用服务器间的 session 复制共享
  3. 基于 cache DB 缓存的 session 共享

Sticky Sessions

需要配置负载均衡器,使得一个用户的所有请求都路由到一个服务器节点上,这样就可以把用户的 Session 存放在该服务器节点中。

缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session。

Session Replication

在服务器节点之间进行 Session 同步操作,这样的话用户可以访问任何一个服务器节点。

缺点:占用过多内存;同步过程占用网络带宽以及服务器处理器时间。

Session Server

使用一个单独的服务器存储 Session 数据,可以存在 MySQL 数据库上,也可以存在 Redis 或者 Memcached 这种内存型数据库。

缺点:需要去实现存取 Session 的代码。

分布式存储

通常有两种解决方案:

  1. 数据分布:就是把数据分块存在不同的服务器上(分库分表)。
  2. 数据复制:让所有的服务器都有相同的数据,提供相当的服务。

分布式缓存

使用缓存的好处:

  • 提升数据读取速度
  • 提升系统扩展能力,通过扩展缓存,提升系统承载能力
  • 降低存储成本,Cache+DB 的方式可以承担原有需要多台 DB 才能承担的请求量,节省机器成本

根据业务场景,通常缓存有以下几种使用方式

  • 懒汉式(读时触发):写入 DB 后, 然后把相关的数据也写入 Cache
  • 饥饿式(写时触发):先查询 DB 里的数据, 然后把相关的数据写入 Cache
  • 定期刷新:适合周期性的跑数据的任务,或者列表型的数据,而且不要求绝对实时性

缓存分类:

  • 应用内缓存:如:EHCache
  • 分布式缓存:如:Memached、Redis

分布式计算

负载均衡

算法

轮询(Round Robin)

轮询算法把每个请求轮流发送到每个服务器上。下图中,一共有 6 个客户端产生了 6 个请求,这 6 个请求按 (1, 2, 3, 4, 5, 6) 的顺序发送。最后,(1, 3, 5) 的请求会被发送到服务器 1,(2, 4, 6) 的请求会被发送到服务器 2。

该算法比较适合每个服务器的性能差不多的场景,如果有性能存在差异的情况下,那么性能较差的服务器可能无法承担过大的负载(下图的 Server 2)。

加权轮询(Weighted Round Robbin)

加权轮询是在轮询的基础上,根据服务器的性能差异,为服务器赋予一定的权值。例如下图中,服务器 1 被赋予的权值为 5,服务器 2 被赋予的权值为 1,那么 (1, 2, 3, 4, 5) 请求会被发送到服务器 1,(6) 请求会被发送到服务器 2。

最少连接(least Connections)

由于每个请求的连接时间不一样,使用轮询或者加权轮询算法的话,可能会让一台服务器当前连接数过大,而另一台服务器的连接过小,造成负载不均衡。例如下图中,(1, 3, 5) 请求会被发送到服务器 1,但是 (1, 3) 很快就断开连接,此时只有 (5) 请求连接服务器 1;(2, 4, 6) 请求被发送到服务器 2,只有 (2) 的连接断开。该系统继续运行时,服务器 2 会承担过大的负载。

最少连接算法就是将请求发送给当前最少连接数的服务器上。例如下图中,服务器 1 当前连接数最小,那么新到来的请求 6 就会被发送到服务器 1 上。

加权最少连接(Weighted Least Connection)

在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。

随机算法(Random)

把请求随机发送到服务器上。和轮询算法类似,该算法比较适合服务器性能差不多的场景。

源地址哈希法 (IP Hash)

源地址哈希通过对客户端 IP 哈希计算得到的一个数值,用该数值对服务器数量进行取模运算,取模结果便是目标服务器的序号。

  • 优点:保证同一 IP 的客户端都会被 hash 到同一台服务器上。
  • 缺点:不利于集群扩展,后台服务器数量变更都会影响 hash 结果。可以采用一致性 Hash 改进。

实现

HTTP 重定向

HTTP 重定向负载均衡服务器收到 HTTP 请求之后会返回服务器的地址,并将该地址写入 HTTP 重定向响应中返回给浏览器,浏览器收到后需要再次发送请求。

缺点:

  • 用户访问的延迟会增加;
  • 如果负载均衡器宕机,就无法访问该站点。

DNS 重定向

使用 DNS 作为负载均衡器,根据负载情况返回不同服务器的 IP 地址。大型网站基本使用了这种方式做为第一级负载均衡手段,然后在内部使用其它方式做第二级负载均衡。

缺点:

  • DNS 查找表可能会被客户端缓存起来,那么之后的所有请求都会被重定向到同一个服务器。

修改 MAC 地址

使用 LVS(Linux Virtual Server)这种链路层负载均衡器,根据负载情况修改请求的 MAC 地址。

修改 IP 地址

在网络层修改请求的目的 IP 地址。

代理自动配置

正向代理与反向代理的区别:

  • 正向代理:发生在客户端,是由用户主动发起的。比如翻墙,客户端通过主动访问代理服务器,让代理服务器获得需要的外网数据,然后转发回客户端。
  • 反向代理:发生在服务器端,用户不知道代理的存在。

PAC 服务器是用来判断一个请求是否要经过代理。

资料

负载均衡

负载均衡简介

大型系统面临的挑战

大型系统通常要面对高并发、高可用、海量数据等挑战。

为了提升系统整体的性能,可以采用垂直扩展和水平扩展两种方式。

  • 垂直扩展:在网站发展早期,可以从单机的角度通过提升硬件处理能力,比如 CPU 处理能力,内存容量,磁盘等方面,实现机器处理能力的提升。但是,单机是有性能瓶颈的,一旦触及瓶颈,再想提升,付出的成本和代价会极高。通俗来说,就三个字:得加钱!这显然不能满足大型分布式系统(网站)所有应对的大流量,高并发,海量数据等挑战。
  • 水平扩展:通过集群来分担大型网站的流量。集群中的应用机器(节点)通常被设计成无状态,用户可以请求任何一个节点,这些节点共同分担访问压力。水平扩展有两个要点:
    • 集群化、分区化:将一个完整的应用化整为零,如果是无状态应用,可以直接集群化部署;如果是有状态应用,可以将状态数据分区(分片),然后部署到多台机器上。
    • 负载均衡:集群化、分区化后,要解决的问题是,请求应该被分发(寻址)到哪台机器上。这就需要通过某种策略来控制分发,这种技术就是负载均衡。

什么是负载均衡

“负载均衡(Load Balance,简称 LB)”是一种技术,用来在多个计算机、网络连接、CPU、磁盘驱动器或其他资源中分配负载,以达到优化资源利用率、最大化吞吐率、最小化响应时间、同时避免过载的目的

负载均衡的主要作用如下:

  • 高并发:负载均衡可以优化资源使用率,通过算法调整负载,尽力均匀的分配资源,以此提高资源利用率、从而提升整体吞吐量。
  • 伸缩性:发生增减资源时,负载均衡可以自动调整分发,使得应用集群具备伸缩性。
  • 高可用:负载均衡器可以监控候选机器,当某机器不可用时,自动跳过,将请求分发给可用的机器。这使得应用集群具备高可用的特性。
  • 安全防护:有些负载均衡软件或硬件提供了安全性功能,如:黑白名单、防火墙,防 DDos 攻击等。

负载均衡的分类

支持负载均衡的技术很多,我们可以通过不同维度去进行分类。

载体维度分类

从支持负载均衡的载体来看,可以将负载均衡分为两类:

  • 硬件负载均衡
  • 软件负载均衡

硬件负载均衡

硬件负载均衡,一般是在定制处理器上运行的独立负载均衡服务器,价格昂贵,土豪专属

硬件负载均衡的主流产品有:F5A10

硬件负载均衡的优点

  • 功能强大:支持全局负载均衡并提供较全面的、复杂的负载均衡算法。
  • 性能强悍:硬件负载均衡由于是在专用处理器上运行,因此吞吐量大,可支持单机百万以上的并发。
  • 安全性高:往往具备防火墙,防 DDos 攻击等安全功能。

硬件负载均衡的缺点

  • 成本昂贵:购买和维护硬件负载均衡的成本都很高。
  • 扩展性差:当访问量突增时,超过限度不能动态扩容。

软件负载均衡

软件负载均衡,应用最广泛,无论大公司还是小公司都会使用。

软件负载均衡从软件层面实现负载均衡,一般可以在任何标准物理设备上运行。

软件负载均衡的 主流产品 有:NginxHAProxyLVS

  • LVS 可以作为四层负载均衡器。其负载均衡的性能要优于 Nginx。
  • HAProxy 可以作为 HTTP 和 TCP 负载均衡器。
  • NginxHAProxy 可以作为四层或七层负载均衡器。

软件负载均衡的 优点

  • 扩展性好:适应动态变化,可以通过添加软件负载均衡实例,动态扩展到超出初始容量的能力。
  • 成本低廉:软件负载均衡可以在任何标准物理设备上运行,降低了购买和运维的成本。

软件负载均衡的 缺点

  • 性能略差:相比于硬件负载均衡,软件负载均衡的性能要略低一些。

网络通信分类

软件负载均衡从通信层面来看,又可以分为四层和七层负载均衡。

  • 七层负载均衡:就是可以根据访问用户的 HTTP 请求头、URL 信息将请求转发到特定的主机。
    • DNS 重定向
    • HTTP 重定向
    • 反向代理
  • 四层负载均衡:基于 IP 地址和端口进行请求的转发。
    • 修改 IP 地址
    • 修改 MAC 地址

DNS 负载均衡

DNS 负载均衡一般用于互联网公司,复杂的业务系统不适合使用。大型网站一般使用 DNS 负载均衡作为 第一级负载均衡手段,然后在内部使用其它方式做第二级负载均衡。DNS 负载均衡属于七层负载均衡。

DNS 即 域名解析服务,是 OSI 第七层网络协议。DNS 被设计为一个树形结构的分布式应用,自上而下依次为:根域名服务器,一级域名服务器,二级域名服务器,… ,本地域名服务器。显然,如果所有数据都存储在根域名服务器,那么 DNS 查询的负载和开销会非常庞大。

因此,DNS 查询相对于 DNS 层级结构,是一个逆向的递归流程,DNS 客户端依次请求本地 DNS 服务器,上一级 DNS 服务器,上上一级 DNS 服务器,… ,根 DNS 服务器(又叫权威 DNS 服务器),一旦命中,立即返回。为了减少查询次数,每一级 DNS 服务器都会设置 DNS 查询缓存。

DNS 负载均衡的工作原理就是:基于 DNS 查询缓存,按照负载情况返回不同服务器的 IP 地址

img

DNS 重定向的 优点

  • 使用简单:负载均衡工作,交给 DNS 服务器处理,省掉了负载均衡服务器维护的麻烦
  • 提高性能:可以支持基于地址的域名解析,解析成距离用户最近的服务器地址(类似 CDN 的原理),可以加快访问速度,改善性能;

DNS 重定向的 缺点

  • 可用性差:DNS 解析是多级解析,新增/修改 DNS 后,解析时间较长;解析过程中,用户访问网站将失败;
  • 扩展性差:DNS 负载均衡的控制权在域名商那里,无法对其做更多的改善和扩展;
  • 维护性差:也不能反映服务器的当前运行状态;支持的算法少;不能区分服务器的差异(不能根据系统与服务的状态来判断负载)

HTTP 负载均衡

HTTP 负载均衡是基于 HTTP 重定向实现的。HTTP 负载均衡属于七层负载均衡。

HTTP 重定向原理是:根据用户的 HTTP 请求计算出一个真实的服务器地址,将该服务器地址写入 HTTP 重定向响应中,返回给浏览器,由浏览器重新进行访问

img

HTTP 重定向的 优点方案简单

HTTP 重定向的 缺点

  • 额外的转发开销:每次访问需要两次请求服务器,增加了访问的延迟。
  • 降低搜索排名:使用重定向后,搜索引擎会视为 SEO 作弊。
  • 如果负载均衡器宕机,就无法访问该站点。

由于其缺点比较明显,所以这种负载均衡策略实际应用较少。

反向代理负载均衡

反向代理(Reverse Proxy)方式是指以 代理服务器 来接受网络请求,然后 将请求转发给内网中的服务器,并将从内网中的服务器上得到的结果返回给网络请求的客户端。反向代理负载均衡属于七层负载均衡。

反向代理服务的主流产品:NginxApache

正向代理与反向代理有什么区别?

  • 正向代理:发生在 客户端,是由用户主动发起的。翻墙软件就是典型的正向代理,客户端通过主动访问代理服务器,让代理服务器获得需要的外网数据,然后转发回客户端。
  • 反向代理:发生在 服务端,用户不知道代理的存在。

img

反向代理是如何实现负载均衡的呢?以 Nginx 为例,如下所示:

img

首先,在代理服务器上设定好负载均衡规则。然后,当收到客户端请求,反向代理服务器拦截指定的域名或 IP 请求,根据负载均衡算法,将请求分发到候选服务器上。其次,如果某台候选服务器宕机,反向代理服务器会有容错处理,比如分发请求失败 3 次以上,将请求分发到其他候选服务器上。

反向代理的 优点

  • 多种负载均衡算法:支持多种负载均衡算法,以应对不同的场景需求。
  • 可以监控服务器:基于 HTTP 协议,可以监控转发服务器的状态,如:系统负载、响应时间、是否可用、连接数、流量等,从而根据这些数据调整负载均衡的策略。

反向代理的 缺点

  • 额外的转发开销:反向代理的转发操作本身是有性能开销的,可能会包括创建连接,等待连接响应,分析响应结果等操作。

  • 增加系统复杂度:反向代理常用于做分布式应用的水平扩展,但反向代理服务存在以下问题,为了解决以下问题会给系统整体增加额外的复杂度和运维成本:

  • 反向代理服务如果自身宕机,就无法访问站点,所以需要有 高可用 方案,常见的方案有:主备模式(一主一备)、双主模式(互为主备)。

    • 反向代理服务自身也存在性能瓶颈,随着需要转发的请求量不断攀升,需要有 可扩展 方案。

IP 负载均衡

IP 负载均衡是在网络层通过修改请求目的地址进行负载均衡。

img

如上图所示,IP 均衡处理流程大致为:

  1. 客户端请求 192.168.137.10,由负载均衡服务器接收到报文。
  2. 负载均衡服务器根据算法选出一个服务节点 192.168.0.1,然后将报文请求地址改为该节点的 IP。
  3. 真实服务节点收到请求报文,处理后,返回响应数据到负载均衡服务器。
  4. 负载均衡服务器将响应数据的源地址改负载均衡服务器地址,返回给客户端。

IP 负载均衡在内核进程完成数据分发,较反向代理负载均衡有更好的处理性能。但是,由于所有请求响应都要经过负载均衡服务器,集群的吞吐量受制于负载均衡服务器的带宽。

数据链路层负载均衡

数据链路层负载均衡是指在通信协议的数据链路层修改 mac 地址进行负载均衡。

img

在 Linux 平台上最好的链路层负载均衡开源产品是 LVS (Linux Virtual Server)。

LVS 是基于 Linux 内核中 netfilter 框架实现的负载均衡系统。netfilter 是内核态的 Linux 防火墙机制,可以在数据包流经过程中,根据规则设置若干个关卡(hook 函数)来执行相关的操作。

LVS 的工作流程大致如下:

  • 当用户访问 www.sina.com.cn 时,用户数据通过层层网络,最后通过交换机进入 LVS 服务器网卡,并进入内核网络层。
  • 进入 PREROUTING 后经过路由查找,确定访问的目的 VIP 是本机 IP 地址,所以数据包进入到 INPUT 链上
  • IPVS 是工作在 INPUT 链上,会根据访问的 vip+port 判断请求是否 IPVS 服务,如果是则调用注册的 IPVS HOOK 函数,进行 IPVS 相关主流程,强行修改数据包的相关数据,并将数据包发往 POSTROUTING 链上。
  • POSTROUTING 上收到数据包后,根据目标 IP 地址(后端服务器),通过路由选路,将数据包最终发往后端的服务器上。

开源 LVS 版本有 3 种工作模式,每种模式工作原理截然不同,说各种模式都有自己的优缺点,分别适合不同的应用场景,不过最终本质的功能都是能实现均衡的流量调度和良好的扩展性。主要包括三种模式:DR 模式、NAT 模式、Tunnel 模式。

负载均衡算法

负载均衡器的实现可以分为两个部分:

  • 根据负载均衡算法在候选机器列表选出一个机器;
  • 将请求数据发送到该机器上。

负载均衡算法是负载均衡服务核心中的核心。负载均衡产品多种多样,但是各种负载均衡算法原理是共性的。

负载均衡算法有很多种,分别适用于不同的应用场景。本章节将由浅入深的,逐一讲解各种负载均衡算法的策略和特性,并根据算法之间的互补关系将它们串联起来。

注:负载均衡算法的实现,推荐阅读 Dubbo 官方负载均衡算法说明 ,源码讲解非常详细,非常值得借鉴。

下文中的各种算法的可执行示例已归档在 Github 仓库:java-load-balance,可以通过执行 io.github.dunwu.javatech.LoadBalanceDemo 查看各算法执行效果。

轮询算法

“轮询算法(Round Robin)”的策略是:将请求“依次”分发到候选机器

如下图所示,轮询负载均衡器收到来自客户端的 6 个请求,编号为 1、4 的请求会被发送到服务端 0;编号为 2、5 的请求会被发送到服务端 1;编号为 3、6 的请求会被发送到服务端 2。

img

轮询算法适合的场景需要满足:各机器处理能力相近,且每个请求工作量差异不大

【示例】轮询负载均衡算法实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RoundRobinLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

private final AtomicInteger position = new AtomicInteger(0);

@Override
protected N doSelect(List<N> nodes, String ip) {
int length = nodes.size();
// 如果位置值已经等于节点数,重置为 0
position.compareAndSet(length, 0);
N node = nodes.get(position.get());
position.getAndIncrement();
return node;
}

}

随机算法

“随机算法(Random)” 将请求“随机”分发到候选机器

如下图所示,随机负载均衡器收到来自客户端的 6 个请求,会随机分发请求,可能会出现:编号为 1、5 的请求会被发送到服务端 0;编号为 2、4 的请求会被发送到服务端 1;编号为 3、6 的请求会被发送到服务端 2。

img

随机算法适合的场景需要满足:各机器处理能力相近,且每个请求工作量差异不大

学习过概率论的都知道,调用量较小的时候,可能负载并不均匀,调用量越大,负载越均衡

【示例】随机负载均衡算法实现示例

负载均衡接口

1
2
3
4
5
public interface LoadBalance<N extends Node> {

N select(List<N> nodes, String ip);

}

负载均衡抽象类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class BaseLoadBalance<N extends Node> implements LoadBalance<N> {

@Override
public N select(List<N> nodes, String ip) {
if (CollectionUtil.isEmpty(nodes)) {
return null;
}

// 如果 nodes 列表中仅有一个 node,直接返回即可,无需进行负载均衡
if (nodes.size() == 1) {
return nodes.get(0);
}

return doSelect(nodes, ip);
}

protected abstract N doSelect(List<N> nodes, String ip);

}

机器节点类

1
2
3
4
5
6
7
8
9
10
public class Node implements Comparable<Node> {

protected String url;

protected Integer weight;

protected Integer active;

// ...
}

随机算法实现

1
2
3
4
5
6
7
8
9
10
11
12
public class RandomLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

private final Random random = new Random();

@Override
protected N doSelect(List<N> nodes, String ip) {
// 在列表中随机选取一个节点
int index = random.nextInt(nodes.size());
return nodes.get(index);
}

}

加权轮询/随机算法

轮询/随机算法适合的场景都需要满足:各机器处理能力相近,且每个请求工作量差异不大。

在理想状况下,假设每个机器的硬件条件相同,如:CPU、内存、网络 IO 等配置都相同;并且每个请求的耗时一样(请求传输时间、请求访问数据时间、计算时间等),这时轮询算法才能真正做到负载均衡。显然,要满足以上条件都相同是几乎不可能的,更不要说实际的网络通信中还有更多复杂的情况。

以上,如果有一点不能满足,都无法做到真正的负载均衡。个体存在较大差异,当请求量较大时,处理较慢的机器可能会逐渐积压请求,从而导致过载甚至宕机。

如下图所示,假设存在这样的场景:

  • 服务端 1 的处理能力远低于服务端 0 和服务端 2;
  • 轮询/随机算法可以保证将请求尽量均匀的分发给两个机器;
  • 编号为 1、4 的请求被发送到服务端 0;编号为 3、6 的请求被发送到服务端 2;二者处理能力强,应对游刃有余;
  • 编号为 2、5 的请求被发送到服务端 1,服务端 1 处理能力弱,应对捉襟见肘,导致过载。

img

《蜘蛛侠》电影中有一句经典台词:能力越大,责任越大。显然,以上情况不符合这句话,处理能力强的机器并没有被分发到更多的请求,它的处理能力被闲置了。那么,如何解决这个问题呢?

一种比较容易想到的思路是:引入权重属性,可以根据机器的硬件条件为其设置合理的权重值,负载均衡时,优先将请求分发到权重较高的机器。

“加权轮询算法(Weighted Round Robbin)” 和“加权随机算法(Weighted Random)” 都采用了加权的思路,在轮询/随机算法的基础上,引入了权重属性,优先将请求分发到权重较高的机器。这样,就可以针对性能高、处理速度快的机器设置较高的权重,让其处理更多的请求;而针对性能低、处理速度慢的机器则与之相反。一言以蔽之,加权策略强调了——能力越大,责任越大。

如下图所示,服务端 0 设置权重为 3,服务端 1 设置权重为 1,服务端 2 设置权重为 2。负载均衡器收到来自客户端的 6 个请求,那么编号为 1、2、5 的请求会被发送到服务端 0,编号为 4 的请求会被发送到服务端 1,编号为 3、6 的请求会被发送到机器 2。

img

【示例】加权随机负载均衡算法实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class WeightRandomLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

private final Random random = ThreadLocalRandom.current();

@Override
protected N doSelect(List<N> nodes, String ip) {

int length = nodes.size();
AtomicInteger totalWeight = new AtomicInteger(0);
for (N node : nodes) {
Integer weight = node.getWeight();
totalWeight.getAndAdd(weight);
}

if (totalWeight.get() > 0) {
int offset = random.nextInt(totalWeight.get());
for (N node : nodes) {
// 让随机值 offset 减去权重值
offset -= node.getWeight();
if (offset < 0) {
// 返回相应的 Node
return node;
}
}
}

// 直接随机返回一个
return nodes.get(random.nextInt(length));
}

}

【示例】加权轮询负载均衡算法实现示例

以下实现基于 Dubbo 加权轮询算法做了一些简化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
public class WeightRoundRobinLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

/**
* 60 秒
*/
private static final int RECYCLE_PERIOD = 60000;

/**
* Node hashcode 到 WeightedRoundRobin 的映射关系
*/
private ConcurrentMap<Integer, WeightedRoundRobin> weightMap = new ConcurrentHashMap<>();

/**
* 原子更新锁
*/
private AtomicBoolean updateLock = new AtomicBoolean();

@Override
protected N doSelect(List<N> nodes, String ip) {

int totalWeight = 0;
long maxCurrent = Long.MIN_VALUE;

// 获取当前时间
long now = System.currentTimeMillis();
N selectedNode = null;
WeightedRoundRobin selectedWRR = null;

// 下面这个循环主要做了这样几件事情:
// 1. 遍历 Node 列表,检测当前 Node 是否有相应的 WeightedRoundRobin,没有则创建
// 2. 检测 Node 权重是否发生了变化,若变化了,则更新 WeightedRoundRobin 的 weight 字段
// 3. 让 current 字段加上自身权重,等价于 current += weight
// 4. 设置 lastUpdate 字段,即 lastUpdate = now
// 5. 寻找具有最大 current 的 Node,以及 Node 对应的 WeightedRoundRobin,
// 暂存起来,留作后用
// 6. 计算权重总和
for (N node : nodes) {
int hashCode = node.hashCode();
WeightedRoundRobin weightedRoundRobin = weightMap.get(hashCode);
int weight = node.getWeight();
if (weight < 0) {
weight = 0;
}

// 检测当前 Node 是否有对应的 WeightedRoundRobin,没有则创建
if (weightedRoundRobin == null) {
weightedRoundRobin = new WeightedRoundRobin();
// 设置 Node 权重
weightedRoundRobin.setWeight(weight);
// 存储 url 唯一标识 identifyString 到 weightedRoundRobin 的映射关系
weightMap.putIfAbsent(hashCode, weightedRoundRobin);
weightedRoundRobin = weightMap.get(hashCode);
}
// Node 权重不等于 WeightedRoundRobin 中保存的权重,说明权重变化了,此时进行更新
if (weight != weightedRoundRobin.getWeight()) {
weightedRoundRobin.setWeight(weight);
}

// 让 current 加上自身权重,等价于 current += weight
long current = weightedRoundRobin.increaseCurrent();
// 设置 lastUpdate,表示近期更新过
weightedRoundRobin.setLastUpdate(now);
// 找出最大的 current
if (current > maxCurrent) {
maxCurrent = current;
// 将具有最大 current 权重的 Node 赋值给 selectedNode
selectedNode = node;
// 将 Node 对应的 weightedRoundRobin 赋值给 selectedWRR,留作后用
selectedWRR = weightedRoundRobin;
}

// 计算权重总和
totalWeight += weight;
}

// 对 weightMap 进行检查,过滤掉长时间未被更新的节点。
// 该节点可能挂了,nodes 中不包含该节点,所以该节点的 lastUpdate 长时间无法被更新。
// 若未更新时长超过阈值后,就会被移除掉,默认阈值为 60 秒。
if (!updateLock.get() && nodes.size() != weightMap.size()) {
if (updateLock.compareAndSet(false, true)) {
try {
// 遍历修改,即移除过期记录
weightMap.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > RECYCLE_PERIOD);
} finally {
updateLock.set(false);
}
}
}

if (selectedNode != null) {
// 让 current 减去权重总和,等价于 current -= totalWeight
selectedWRR.decreaseCurrent(totalWeight);
// 返回具有最大 current 的 Node
return selectedNode;
}

// should not happen here
return nodes.get(0);
}

protected static class WeightedRoundRobin {

// 服务提供者权重
private int weight;
// 当前权重
private AtomicLong current = new AtomicLong(0);
// 最后一次更新时间
private long lastUpdate;

public long increaseCurrent() {
// current = current + weight;
return current.addAndGet(weight);
}

public long decreaseCurrent(int total) {
// current = current - total;
return current.addAndGet(-1 * total);
}

public int getWeight() {
return weight;
}

public void setWeight(int weight) {
this.weight = weight;
// 初始情况下,current = 0
current.set(0);
}

public AtomicLong getCurrent() {
return current;
}

public void setCurrent(AtomicLong current) {
this.current = current;
}

public long getLastUpdate() {
return lastUpdate;
}

public void setLastUpdate(long lastUpdate) {
this.lastUpdate = lastUpdate;
}

}

}

最少连接数算法

加权轮询/随机算法虽然一定程度上解决了机器处理能力不同时的负载均衡场景,但它最大的问题在于不能动态应对网络中负载不均的场景。加权的思路是在负载均衡处理的事前,预设好不同机器的权重,然后分发。然而,每个请求的连接时长不同,负载均衡器也不可能准确预估出请求的连接时长。因此,采用加权轮询/随机算法算法,都无法动态应对连接时长不均的网络场景,可能会出现某些机器当前连接数过多,而另一些机器的连接过少的情况,即并非真正的流量负载均衡。

如下图所示,假设存在这样的场景:

  • 3 个服务端的处理能力相同;
  • 编号为 1、4 的请求被发送到服务端 0,但是 1 很快就断开连接,此时只有 4 请求连接服务端 0;
  • 编号为 2、5 的请求被发送到服务端 1,但是 2 始终保持长连接;该系统继续运行时,服务端 1 发生过载;
  • 编号为 3、6 的请求被发送到服务端 2,但是 3 很快就断开连接,此时只有 6 请求连接服务端 2;

img

既然,请求的连接时长不同,会导致有的服务端处理慢,积压大量连接数;而有的服务端处理快,保持的连接数少。那么,我们不妨想一下,如果负载均衡器监控一下服务端当前所持有的连接数,优先将请求分发给连接数少的服务端,不就能有效提高分发效率了吗?最少连接数算法正是采用这个思路去设计的。

“最少连接数算法(Least Connections)” 将请求分发到连接数/请求数最少的候选机器

要根据机器连接数分发,显然要先维护机器的连接数。因此,最少连接数算法需要实时追踪每个候选机器的活跃连接数;然后,动态选出连接数最少的机器,优先分发请求。最少连接数算法会记录当前时刻,每个候选节点正在处理的连接数,然后选择连接数最小的节点。该策略能够动态、实时地反应机器的当前状况,较为合理地将负责分配均匀,适用于对当前系统负载较为敏感的场景。

由此可见,最少连接数算法适用于对系统负载较为敏感且请求连接时长相差较大的场景

如下图所示,假设存在这样的场景:

  • 服务端 0 和服务端 1 的处理能力相同;
  • 编号为 1、3 的请求被发送到服务端 0,但是 1、3 很快就断开连接;
  • 编号为 2、4 的请求被发送到服务端 1,但是 2、4 保持长连接;
  • 由于服务端 0 当前连接数最少,编号为 5、6 的请求被分发到服务端 0。

img

“加权最少连接数算法(Weighted Least Connection)”在最少连接数算法的基础上,根据机器的性能为每台机器分配权重,再根据权重计算出每台机器能处理的连接数。

【示例】最少连接数算法实现

最少连接数算法实现要点:活跃调用数越小,表明该服务节点处理能力越高,单位时间内可处理更多的请求,应优先将请求分发给该服务。在具体实现中,每个服务节点对应一个活跃数 active。初始情况下,所有服务提供者活跃数均为 0。每收到一个请求,活跃数加 1,完成请求后则将活跃数减 1。在服务运行一段时间后,性能好的服务提供者处理请求的速度更快,因此活跃数下降的也越快,此时这样的服务提供者能够优先获取到新的服务请求、这就是最少连接数负载均衡算法的基本思想。

以下实现基于 Dubbo 最少连接数负载均衡算法做了些许改动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class LeastActiveLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

private final Random random = new Random();

@Override
protected N doSelect(List<N> nodes, String ip) {
int length = nodes.size();
// 最小的活跃数
int leastActive = -1;
// 具有相同“最少连接数”的服务者提供者(以下用 Node 代称)数量
int leastCount = 0;
// leastIndexs 用于记录具有相同“最少连接数”的 Node 在 nodes 列表中的下标信息
int[] leastIndexs = new int[length];
int totalWeight = 0;
// 第一个最少连接数的 Node 权重值,用于与其他具有相同最少连接数的 Node 的权重进行对比,
// 以检测是否“所有具有相同最少连接数的 Node 的权重”均相等
int firstWeight = 0;
boolean sameWeight = true;

// 遍历 nodes 列表
for (int i = 0; i < length; i++) {
N node = nodes.get(i);
// 发现更小的活跃数,重新开始
if (leastActive == -1 || node.getActive() < leastActive) {
// 使用当前活跃数更新最少连接数 leastActive
leastActive = node.getActive();
// 更新 leastCount 为 1
leastCount = 1;
// 记录当前下标值到 leastIndexs 中
leastIndexs[0] = i;
totalWeight = node.getWeight();
firstWeight = node.getWeight();
sameWeight = true;

// 当前 Node 的活跃数 node.getActive() 与最少连接数 leastActive 相同
} else if (node.getActive() == leastActive) {
// 在 leastIndexs 中记录下当前 Node 在 nodes 集合中的下标
leastIndexs[leastCount++] = i;
// 累加权重
totalWeight += node.getWeight();
// 检测当前 Node 的权重与 firstWeight 是否相等,
// 不相等则将 sameWeight 置为 false
if (sameWeight && i > 0
&& node.getWeight() != firstWeight) {
sameWeight = false;
}
}
}

// 当只有一个 Node 具有最少连接数,此时直接返回该 Node 即可
if (leastCount == 1) {
return nodes.get(leastIndexs[0]);
}

// 有多个 Node 具有相同的最少连接数,但它们之间的权重不同
if (!sameWeight && totalWeight > 0) {
// 随机生成一个 [0, totalWeight) 之间的数字
int offsetWeight = random.nextInt(totalWeight);
// 循环让随机数减去具有最少连接数的 Node 的权重值,
// 当 offset 小于等于 0 时,返回相应的 Node
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexs[i];
// 获取权重值,并让随机数减去权重值
offsetWeight -= nodes.get(leastIndex).getWeight();
if (offsetWeight <= 0) {
return nodes.get(leastIndex);
}
}
}
// 如果权重相同或权重为 0 时,随机返回一个 Node
return nodes.get(leastIndexs[random.nextInt(leastCount)]);
}

}

最少响应时间算法

“最少响应时间算法(Least Time)” 将请求分发到响应时间最短的候选机器。最少响应时间算法和最少连接数算法二者的目标其实是殊途同归,都是动态调整,将请求尽量分发到处理能力强的机器上。不同点在于,最少连接数关注的维度是机器持有的连接数,而最少响应时间关注的维度是机器上一次响应时间哪个最短。理论上来说,持有的连接数少,响应时间短,都可以表明机器潜在的处理能力比较强。

最少响应时间算法具有高度的敏感性、自适应性。但是,由于它需要持续监控候选机器的响应时延,相比于监控候选机器的连接数,会显著增加监控的开销。此外,请求的响应时延并不一定能完全反应机器的处理能力,有可能某机器上一次处理的请求恰好是一个开销非常小的请求。

img

哈希算法

前面提到的负载均衡算法,都只适用于无状态应用。所谓无状态应用,意味着:请求无论分发到集群中的任意机器上,得到的响应都是相同的:然而,有状态服务则不然:请求分发到不同的机器上,得到的结果是不一样的。典型的无状态应用是普通的 Web 服务器;典型的有状态应用是各种分布式数据库(如:Redis、ElasticSearch 等),这些数据库存储了大量,乃至海量的数据,无法全部存储在一台机器上,为了提高整体容量以及吞吐量,采用了分区(分片)的设计,将数据化整为零的存储在不同机器上。

对于有状态应用,不仅仅需要保证负载的均衡,更为重要的是,需要保证针对相同数据的请求始终访问的是相同的机器,否则,就无法获取到正确的数据。

那么,如何解决有状态应用的负载均衡呢?有一种方案是哈希算法。

“哈希算法(Hash)” 根据一个 key (可以是唯一 ID、IP、URL 等),通过哈希函数计算得到一个数值,用该数值在候选机器列表的进行取模运算,得到的结果便是选中的机器

img

这种算法可以保证,同一关键字(IP 或 URL 等)的请求,始终会被转发到同一台机器上。哈希负载均衡算法常被用于实现会话粘滞(Sticky Session)。

但是 ,哈希算法的问题是:当增减节点时,由于哈希取模函数的基数发生变化,会影响大部分的映射关系,从而导致之前的数据不可访问。要解决这个问题,就必须根据新的计算公式迁移数据。显然,如果数据量很大的情况下,迁移成本很高;并且,在迁移过程中,要保证业务平滑过渡,需要使用数据双写等较为复杂的技术手段。

img

【示例】源地址哈希算法实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IpHashLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

@Override
protected N doSelect(List<N> nodes, String ip) {
if (StrUtil.isBlank(ip)) {
ip = "127.0.0.1";
}

int length = nodes.size();
int index = hash(ip) % length;
return nodes.get(index);
}

public int hash(String text) {
return HashUtil.fnvHash(text);
}

}

一致性哈希算法

哈希算法的缺点是:当集群中出现增减节点时,由于哈希取模函数的基数发生变化,会导致大量集群中的机器不可用;需要通过代价高昂的数据迁移,来解决问题。那么,我们自然会希望有一种更优化的方案,来尽量减少影响的机器数。一致性哈希算法就是为了这个目标而应运而生。

一致性哈希算法对哈希算法进行了改良。“一致性哈希算法(Consistent Hash)”,根据哈希算法将对应的 key 哈希到一个具有 2^32 个桶的空间,并且头尾相连(0 到 2^32-1),即一个闭合的环形,这个圆环被称为“哈希环”。哈希算法是对节点的数量进行取模运算;而一致性哈希算法则是对 2^32 进行取模运算。

哈希环的空间是按顺时针方向组织的,需要对指定 key 的数据进行读写时,会执行两步:

  1. 先对节点进行哈希计算,计算的关键字通常是 IP 或其他唯一标识(例:hash(ip)),然后对 2^32 取模,以确定节点在哈希环上的位置。
  2. 先对 key 进行哈希计算(hash(key)),然后对 2^32 取模,以确定 key 在哈希环上的位置。
  3. 然后根据 key 的位置,顺时针找到的第一个节点,就是 key 对应的节点。

所以,一致性哈希是将“存储节点”和“数据”都映射到一个顺时针排序的哈希环上

img

一致性哈希算法会尽可能保证,相同的请求被分发到相同的机器上。当出现增减节点时,只影响哈希环中顺时针方向的相邻的节点,对其他节点无影响,不会引起剧烈变动

  • 相同的请求是指:一般在使用一致性哈希时,需要指定一个 key 用于 hash 计算,可能是:用户 ID、请求方 IP、请求服务名称,参数列表构成的串
  • 尽可能是指:哈希环上出现增减节点时,少数机器的变化不应该影响大多数的请求。

(1)增加节点

如下图所示,假设,哈希环中新增了一个节点 S4,新增节点经过哈希计算映射到图中位置:

img

此时,只有 K1 收到影响;而 K0、K2 均不受影响。

(2)减少节点

如下图所示,假设,哈希环中减少了一个节点 S0:

img

此时,只有 K0 收到影响;而 K1、K2 均不受影响。

一致性哈希算法并不保证节点能够在哈希环上分布均匀,由此而产生一个问题,哈希环上可能有大量的请求集中在一个节点上。从概率角度来看,哈希环上的节点越多,分布就越均匀。正因为如此,一致性哈希算法不适用于节点数过少的场景。

如下图所示:极端情况下,可能由于节点在哈希环上分布不均,有大量请求计算得到的 key 会被集中映射到少数节点,甚至某一个节点上。此外,节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,从而引发雪崩式的连锁反应。

img

【示例】一致性哈希算法示例

以下示例基于 Dubbo 的一致性哈希负载均衡算法做了一些简化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
public class ConsistentHashLoadBalance<N extends Node> extends BaseLoadBalance<N> implements LoadBalance<N> {

private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors = new ConcurrentHashMap<>();

@SuppressWarnings("unchecked")
@Override
protected N doSelect(List<N> nodes, String ip) {
// 分片数,这里设为节点数的 4 倍
Integer replicaNum = nodes.size() * 4;
// 获取 nodes 原始的 hashcode[11. 分布式协同](..%2F11.%B7%D6%B2%BC%CA%BD%D0%AD%CD%AC)
int identityHashCode = System.identityHashCode(nodes);

// 如果 nodes 是一个新的 List 对象,意味着节点数量发生了变化
// 此时 selector.identityHashCode != identityHashCode 条件成立
ConsistentHashSelector<N> selector = (ConsistentHashSelector<N>) selectors.get(ip);
if (selector == null || selector.identityHashCode != identityHashCode) {
// 创建新的 ConsistentHashSelector
selectors.put(ip, new ConsistentHashSelector<>(nodes, identityHashCode, replicaNum));
selector = (ConsistentHashSelector<N>) selectors.get(ip);
}
// 调用 ConsistentHashSelector 的 select 方法选择 Node
return selector.select(ip);
}

/**
* 一致性哈希选择器
*/
private static final class ConsistentHashSelector<N extends Node> {

/**
* 存储虚拟节点
*/
private final TreeMap<Long, N> virtualNodes;

private final int identityHashCode;

/**
* 构造器
*
* @param nodes 节点列表
* @param identityHashCode hashcode
* @param replicaNum 分片数
*/
ConsistentHashSelector(List<N> nodes, int identityHashCode, Integer replicaNum) {
this.virtualNodes = new TreeMap<>();
this.identityHashCode = identityHashCode;
// 获取虚拟节点数,默认为 100
if (replicaNum == null) {
replicaNum = 100;
}
for (N node : nodes) {
for (int i = 0; i < replicaNum / 4; i++) {
// 对 url 进行 md5 运算,得到一个长度为 16 的字节数组
byte[] digest = md5(node.getUrl());
// 对 digest 部分字节进行 4 次 hash 运算,得到四个不同的 long 型正整数
for (int j = 0; j < 4; j++) {
// h = 0 时,取 digest 中下标为 0 ~ 3 的 4 个字节进行位运算
// h = 1 时,取 digest 中下标为 4 ~ 7 的 4 个字节进行位运算
// h = 2, h = 3 时过程同上
long m = hash(digest, j);
// 将 hash 到 node 的映射关系存储到 virtualNodes 中,
// virtualNodes 需要提供高效的查询操作,因此选用 TreeMap 作为存储结构
virtualNodes.put(m, node);
}
}
}
}

public N select(String key) {
// 对参数 key 进行 md5 运算
byte[] digest = md5(key);
// 取 digest 数组的前四个字节进行 hash 运算,再将 hash 值传给 selectForKey 方法,
// 寻找合适的 Node
return selectForKey(hash(digest, 0));
}

private N selectForKey(long hash) {
// 查找第一个大于或等于当前 hash 的节点
Map.Entry<Long, N> entry = virtualNodes.ceilingEntry(hash);
// 如果 hash 大于 Node 在哈希环上最大的位置,此时 entry = null,
// 需要将 TreeMap 的头节点赋值给 entry
if (entry == null) {
entry = virtualNodes.firstEntry();
}
// 返回 Node
return entry.getValue();
}

}

/**
* 计算 hash 值
*/
public static long hash(byte[] digest, int number) {
return (((long) (digest[3 + number * 4] & 0xFF) << 24)
| ((long) (digest[2 + number * 4] & 0xFF) << 16)
| ((long) (digest[1 + number * 4] & 0xFF) << 8)
| (digest[number * 4] & 0xFF))
& 0xFFFFFFFFL;
}

/**
* 计算 MD5 值
*/
public static byte[] md5(String value) {
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e.getMessage(), e);
}
md5.reset();
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
md5.update(bytes);
return md5.digest();
}

}

虚拟一致性哈希算法

在一致性哈希算法中,如果节点数过少,可能会分布不均,从而导致负载不均衡。在实际生产环境中,一个分布式系统应该具备良好的伸缩性,既能从容的扩展到大规模的集群,也要能支持小规模的集群。为此,又产生了虚拟哈希算法,进一步对一致性哈希算法进行了改良。

虚拟哈希算法的解决思路是:虽然实际的集群可能节点数较少,但是在哈希环上引入大量的虚拟哈希节点。具体来说,“虚拟哈希算法”有二次映射:先将虚拟节点映射到哈希环上,再将虚拟节点映射到实际节点上。

如下图所示,假设存在这样的场景:

  • 分布式集群中有 4 个真实节点,分别是:S0、S1、S2、S3;
  • 我们不妨先假定分配给哈希环 12 个虚拟节点,并将虚拟节点映射到真实节点上,映射关系如下:
    • S0 - S0_0、S0_1、S0_2、S0_3
    • S1 - S1_0、S1_1、S1_2、S1_3
    • S2 - S2_0、S2_1、S2_2、S2_3
    • S3 - S3_0、S3_1、S3_2、S3_3

img

通过引入虚拟哈希节点,是的哈希环上的节点分布相对均匀了。举例来说,假如此时,某请求的 key 哈希取模后,先映射到哈希环的 [S3_2, S0_0]、[S3_0, S0_1]、[S3_1, S0_2] 这三个区间的任意一点;接下来的二次映射都会匹配到真实节点 S0。

在实际应用中,虚拟哈希节点数一般都比较大(例如:Redis 的虚拟哈希槽有 16384 个),较大的数量保证了虚拟哈希环上的节点分布足够均匀。

虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。例如,当某个节点被移除时,分配给该节点的多个虚拟节点会被一并移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。

此外,有了虚拟节点后,可以通过调整分配给真实节点的虚拟节点数,来达到设置权重一样的效果,使得负载均衡更加灵活。

综上所述,虚拟一致性哈希算法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景

小结

下面通过一张思维导图对介绍的负载均衡算法做一个小结:

img

参考资料

系统架构概述

大型系统架构演化

一个大型系统的架构是一个渐进的演化过程。罗马不是一天建成的,同理,微信、淘宝等大型系统绝不是一蹴而就的。随着业务的不断发展,用户体量的增加,系统的复杂度势必不断攀升,最终迫使系统架构进化,以应对挑战。

了解大型系统架构的演化过程,有利于我们了解架构进化的发展规律和业界一些成熟的应对方案。帮助我们在实际工作中,如何去思考架构,如何去凝练解决方案。

大型系统架构演化比较具有代表性的就是大型网站的演化过程。这里介绍一下大型网站演化的一般规律。

单机架构

  • 问题:网站运营初期,访问用户少,一台服务器绰绰有余。
  • 特征应用程序、数据库、文件等所有的资源都在一台服务器上。
  • 描述:通常服务器操作系统使用 linux,应用程序使用 PHP 开发,然后部署在 Apache 上,数据库使用 Mysql,通俗称为 LAMP。汇集各种免费开源软件以及一台廉价服务器就可以开始系统的发展之路了。

应用服务和数据服务分离

  • 问题:越来越多的用户访问导致性能越来越差,越来越多的数据导致存储空间不足,一台服务器已不足以支撑。
  • 特征应用服务器、数据库服务器、文件服务器分别独立部署。
  • 描述:三台服务器对性能要求各不相同:
    • 应用服务器要处理大量业务逻辑,因此需要更快更强大的 CPU;
    • 数据库服务器需要快速磁盘检索和数据缓存,因此需要更快的硬盘和更大的内存;
    • 文件服务器需要存储大量文件,因此需要更大容量的硬盘。

使用缓存改善性能

  • 问题:随着用户逐渐增多,数据库压力太大导致访问延迟。
  • 特征:由于网站访问和财富分配一样遵循二八定律:_80% 的业务访问集中在 20% 的数据上_。将数据库中访问较集中的少部分数据缓存在内存中,可以减少数据库的访问次数,降低数据库的访问压力。
  • 描述:缓存分为两种:应用服务器上的本地缓存和分布式缓存服务器上的远程缓存。
    • 本地缓存访问速度更快,但缓存数据量有限,同时存在与应用程序争用内存的情况。
    • 分布式缓存可以采用集群方式,理论上可以做到不受内存容量限制的缓存服务。

负载均衡

  • 问题:使用缓存后,数据库访问压力得到有效缓解。但是单一应用服务器能够处理的请求连接有限,在访问高峰期,成为瓶颈。
  • 特征多台服务器通过负载均衡同时向外部提供服务,解决单一服务器处理能力和存储空间不足的问题。
  • 描述:使用集群是系统解决高并发、海量数据问题的常用手段。通过向集群中追加资源,提升系统的并发处理能力,使得服务器的负载压力不再成为整个系统的瓶颈。

数据库读写分离

  • 问题:网站使用缓存后,使绝大部分数据读操作访问都可以不通过数据库就能完成,但是仍有一部分读操作和全部的写操作需要访问数据库,在网站的用户达到一定规模后,数据库因为负载压力过高而成为网站的瓶颈。
  • 特征:目前大部分的主流数据库都提供主从热备功能,通过配置两台数据库主从关系,可以将一台数据库服务器的数据更新同步到一台服务器上。网站利用数据库的主从热备功能,实现数据库读写分离,从而改善数据库负载压力。
  • 描述:应用服务器在写操作的时候,访问主数据库,主数据库通过主从复制机制将数据更新同步到从数据库。这样当应用服务器在读操作的时候,访问从数据库获得数据。为了便于应用程序访问读写分离后的数据库,通常在应用服务器端使用专门的数据访问模块,使数据库读写分离的对应用透明。

多级缓存

  • 问题:中国网络环境复杂,不同地区的用户访问网站时,速度差别也极大。
  • 特征采用 CDN 和反向代理加快系统的静态资源访问速度。
  • 描述:CDN 和反向代理的基本原理都是缓存,区别在于:
    • CDN 部署在网络提供商的机房,使用户在请求网站服务时,可以从距离自己最近的网络提供商机房获取数据;
    • 而反向代理则部署在网站的中心机房,当用户请求到达中心机房后,首先访问的服务器时反向代理服务器,如果反向代理服务器中缓存着用户请求的资源,就将其直接返回给用户。

业务拆分

  • 问题:大型网站的业务场景日益复杂,分为多个产品线。
  • 特征:采用分而治之的手段将整个网站业务分成不同的产品线。系统上按照业务进行拆分改造,应用服务器按照业务区分进行分别部署。
  • 描述:应用之间可以通过超链接建立关系,也可以通过消息队列进行数据分发,当然更多的还是通过访问同一个数据存储系统来构成一个关联的完整系统。
    • 纵向拆分将一个大应用拆分为多个小应用,如果新业务较为独立,那么就直接将其设计部署为一个独立的 Web 应用系统。纵向拆分相对较为简单,通过梳理业务,将较少相关的业务剥离即可。
    • 横向拆分将复用的业务拆分出来,独立部署为分布式服务,新增业务只需要调用这些分布式服务横向拆分需要识别可复用的业务,设计服务接口,规范服务依赖关系。

分库分表

  • 问题:随着大型网站业务持续增长,数据库经过读写分离,从一台服务器拆分为两台服务器,依然不能满足需求。
  • 特征数据库采用分布式数据库。
  • 描述:分布式数据库是数据库拆分的最后方法,只有在单表数据规模非常庞大的时候才使用。不到不得已时,更常用的数据库拆分手段是业务分库,将不同的业务数据库部署在不同的物理服务器上。

分布式组件

  • 问题:随着网站业务越来越复杂,对数据存储和检索的需求也越来越复杂。
  • 特征系统引入 NoSQL 数据库及搜索引擎。
  • 描述:NoSQL 数据库及搜索引擎对可伸缩的分布式特性具有更好的支持。应用服务器通过统一数据访问模块访问各种数据,减轻应用程序管理诸多数据源的麻烦。

微服务

  • 问题:随着业务越拆越小,存储系统越来越庞大,应用系统整体复杂程度呈指数级上升,部署维护越来越困难。由于所有应用要和所有数据库系统连接,最终导致数据库连接资源不足,拒绝服务。
  • 特征公共业务提取出来,独立部署。由这些可复用的业务连接数据库,通过分布式服务提供共用业务服务。
  • 描述:大型网站的架构演化到这里,基本上大多数的技术问题都得以解决,诸如跨数据中心的实时数据同步和具体网站业务相关的问题也都可以组合改进现有技术架构来解决。

架构设计的考量

每一个模式描述了一个不但重复发生的问题及该问题解决方案的核心。这样,就可以不断复用该方案而减少重复工作

什么是架构

架构是一个非常抽象的概念,每个人由于技术的深度、思维的视角等差异,对于架构的理解,各不相同。

这里摘抄网上某段比较精髓的定义:

  • 架构是软件系统的顶层设计
  • 框架是面向编程或配置的半成品
  • 组件是从技术维度上的复用
  • 模块是从业务维度上职责的划分
  • 系统是相互协同可运行的实体

架构设计的目标

架构设计的主要目的是为了解决软件系统复杂度带来的问题

架构设计应该按需设计。任何网站都是随着业务逐步发展,不断演化而成,不要指望一劳永逸。

关于架构设计的目的,常见的误区有:

  • 因为架构很重要,所以要做架构设计
  • 为了高性能、高可用、可扩展,所以要做架构设计
  • 大厂都是这么做的,所以我们也这么做
  • 这种新技术很牛逼,我们也一定要引入

架构的原则:

  • 架构设计应该按需设计。任何网站都是随着业务逐步发展,不断演化而成,不要指望一劳永逸。
  • 驱动技术发展的主要力量是业务发展
  • 不要盲目跟风大公司的解决方案。
  • 不要盲目追求流行技术,而脱离了业务发展的实际情况。
  • 不要把所有问题都丢给技术。现实中,有很多案例告诉我们,很多问题不一定需要通过技术来解决。归根结底,技术始终都是业务的辅助,业务问题究竟是通过技术来解决还是直接通过业务来解决,需要根据实际情况去分析判断。这就需要对业务领域有比较深入的理解和思考。

架构设计的原则

合适优于先进>演化优于一步到位>简单优于复杂

合适原则

没那么多人,却想干那么多活,是失败的第一个主要原因。

没有那么多积累,却想一步登天,是失败的第二个主要原因。

没有那么卓越的业务场景,却幻想灵光一闪成为天才,是失败的第三个主要原因。

简单原则

再高大上的解决方案如果不能落地,也是白扯。

所以,应对需求

演化原则

演化优于一步到位。

不要妄图设计一个一步到位,永久不变的架构。

墨菲定律

  • 任何事都没有表面看起来那么简单;
  • 所有的事都会比你预计的时间长;
  • 会出错的事总会出错;
  • 如果你担心某种情况发生,那么它就更有可能发生。

康威定律

系统设计(产品结构)等同组织形式,每个设计系统的组织,其产生的设计等同于组织之间的沟通结构(简单点说就是,系统的设计受限于设计系统的组织的人员架构形式。

二八定律

高性能

性能是软件系统的重要衡量标准。很多扩展性、伸缩性、可用性的问题,是为了解决性能问题而引入的。

性能指标

响应延时、并发处理能力、内存、CPU、IO 开销等都可以视为系统的性能指标。

分析用户体量、日访问量的峰值,估算出为了平稳应对峰值访问流量所需的并发量、吞吐量。如果是应用型系统,性能够用就好,没必要一味追求高性能。比如:用户体量可能还不过万,一天总访问量可能也就一两千 PV,峰值也就几百 QPS,这样的系统如果要考虑每秒几万的 QPS,显然有些多虑了。

性能提升手段

常见的性能提升手段有:

  • 前端
    • 浏览器缓存
    • 静态资源压缩
    • 合理布局页面
    • 减少 cookie 传输
    • CDN
  • 应用服务
    • 负载均衡和反向代理
    • 本地缓存
    • 分布式缓存
    • 异步消息队列
    • 集群
    • 代码层面:使用多线程、改善内存管理
  • 数据库
    • 索引
    • 数据库缓存
    • SQL 优化

注意:缓存是改善软件性能的第一手段。缓存除了可以加快数据访问速度以外,还可以减轻后端应用和数据存储的负载压力。所以,如果要提升系统性能,应该第一时间想到缓存。

使用缓存有两个前提:

  • 数据访问热点不均匀,频繁访问的数据应该放在缓存中。
  • 数据在某个时间段有效,不会很快过期,否则缓存数据会因已经失效而产生脏读。

高可用

系统无中断地执行其功能的能力,代表系统的可用性程度,是进行系统设计时的准则之一。

高性能增加机器目的在于“扩展”处理性能;高可用增加机器目的在于“冗余”处理单元

单点系统,是无法保证高可用的。系统自身故障、断电、硬件故障、网络等等,都可能导致服务不可用。高可用方案五花八门,本质上都是通过“冗余”来实现高可用。

无状态应用的高可用

无状态应用一般具有幂等性,即无论在哪台机器上进行计算,同样的算法和输入数据,产出的结果都是一样的。所以,计算在任意节点服务器上执行,结果都一样。

无状态应用的高可用:

  • 需要增加一个任务分配器,选择合适的任务分配器也是一件复杂的事情,需要综合考虑性能、成本、可维护性、可用性等各方面因素。
  • 任务分配器和真正的业务服务器之间有连接和交互,需要选择合适的连接方式,并且对连接进行管理。例如,连接建立、连接检测、连接中断后如何处理等。
  • 任务分配器需要增加分配算法。例如,常见的双机算法有主备、主主,主备方案又可以细分为冷备、温备、热备。

有状态应用的高可用

有状态应用,是指需要存储数据的系统,比如各种分布式存储。和无状态应用相比,有一个本质上的区别:各节点需要通过同步保持数据一致。分布式领域里面有一个著名的 CAP 定理,从理论上论证了存储高可用的复杂度。也就是说,存储高可用不可能同时满足“一致性、可用性、分区容错性”,最多满足其中两个,这就要求我们在做架构设计时结合业务进行取舍。

高可用手段

高可用的常用手段:

  • 负载均衡 - 通过负载均衡设备建立集群共同对外提供服务。
  • 备份 - 数据存储在多台服务器,互相备份。即使访问和负载很小的服务也必须部署至少两台服务器,构成一个集群,目的就是通过冗余实现服务的高可用。
    • 冷备份 - 数据应该定期备份;
    • 热备份 - 为了保证在线业务高可用,还需要对数据库进行主从分离,实时同步 。
    • 灾备 - 为了抵御地震、海啸等不可抗因素导致的网站完全瘫痪,某些大型网站会对整个数据中心进行备份,全球范围内部署 灾备数据中心。网站程序和数据定期同步到多个灾备数据中心。
  • 自动化 - 自动化是指,大型系统有必要通过预发布验证、自动化测试、自动化发布、灰度发布等手段,减少将故障引入线上环境的可能。常见自动化手段有:
    • 发布过程自动化
      • 自动化代码管理
      • 自动化测试
      • 自动化安全监测
      • 自动化部署
    • 运维自动化
      • 自动化监控
      • 自动化报警
      • 自动化失效转移
      • 自动化失效恢复
      • 自动化降级
      • 自动化分配资源

扩展性

可扩展性指系统为了应对将来需求变化而提供的一种扩展能力,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建。

衡量扩展性的标准就是增加新的业务产品时,是否可以实现对现有产品透明无影响,不需要任何改动或很少改动,既有功能就可以上线新产品。

软件发展的一个重要目标和驱动力是降低软件耦合性。事物之间直接关系越少,彼此影响就越小,也就更容易独立发展,即扩展性好。

主要手段有:

  • 分层 - 分层是扩展性设计的最基本手段。通过分层,可以将一个的软件系统切分为不同的部分,便于分工合作开发和维护;各层间具有一定的独立性。

    • 分层架构的约束:禁止跨层次的调用及逆向调用
    • 即使系统规模很小,也应该考虑采用分层的架构,这样便于以后扩展。
  • 分割 - 将不同的功能和服务分割开来,包装成高内聚、低耦合的模块单元。这有助于软件的开发和维护,便于不同模块的分布式部署,提高系统的并发处理能力和功能扩展能力。

  • 异步 - 业务间的消息传递不是同步调用,而是将一个业务操作拆分成多阶段,每个阶段间通过共享数据的方式异步执行进行协作。

    • 在单一服务器内部可通过多线程共享内存队列的方式实现异步,处在业务操作前面的线程将操作输出到队列,后面的线程从队列中读取数据进行处理;
    • 在分布式系统中,多个服务器集群通过分布式消息队列实现异步
  • 分布式 - 将业务和可复用服务分离,通过分布式服务框架调用。分布式是指多台服务器部署相同应用构成一个集群,通过负载均衡设备共同对外提供服务。着意味着服务可以用更多的机器工作,即扩展 CPU、内存、IO 等资源,从而提高系统整体的吞吐量和并发处理能力。

    • 常用的分布式方案:
      • 分布式应用和服务
      • 分布式静态资源
      • 分布式数据和存储
      • 分布式计算
    • 分布式也引入了一些问题:
      • 服务调用必须通过网络,网络延迟会影响性能。
      • 服务器越多,宕机概率也越大,导致可用性降低。
      • 数据一致性非常困难,分布式事务也难以保证。
      • 网站依赖错综复杂,开发管理维护困难。

伸缩性

衡量伸缩的标准就是是否可以用多台服务器构建集群,是否容易向集群中增删服务器节点。增删服务器节点后是否可以提供和之前无差别的服务。集群中可容纳的总服务器数是否有限制。

伸缩性是指通过增/减服务器节点数,来灵活的提高/降低系统处理能力

主要手段有:

  • 应用服务器集群 - 只要服务器上保存数据,则所有服务器都是对等的,通过负载均衡设备向集群中不断加入服务器即可
  • 缓存服务器集群 - 加入新的服务器可能会导致缓存路由失效,进而导致集群中的大部分缓存数据都无法访问。虽然缓存数据可以通过数据库重新加载,但是如果应用严重依赖缓存,可能会导致网站崩溃。需要改进缓存路由算法保证缓存数据的可访问性。
  • 关系型数据库集群 - 关系型数据库虽然支持数据复制,主从热备等机制,但是很难做到大规模集群的可伸缩性,因此关系型数据库的集群伸缩性方案必须在数据库之外实现,通过路由分区等手段将部署有多个数据库的服务器组成一个集群。
  • Nosql 数据库集群 - 由于先天就是为了应对海量数据而产生,因此对伸缩性的支持通常都非常好。

安全性

安全是指系统应该对恶意攻击有一定的抵抗能力,保护重要数据不被窃取。

  • 密码手机校验码 进行身份认证
  • 登录、交易等重要操作需要对网络通信进行 加密,存储的敏感数据如用户信息等也进行加密处理
  • 防止机器人程序攻击网站,使用 验证码 进行识别
  • 对常见用于 攻击 网站的 XSS 攻击、SQL 注入、进行编码转换等相应处理
  • 对垃圾信息、敏感信息进行 过滤
  • 对交易转账等重要操作根据交易模式和交易信息进行 风险控制

常见架构模型

分层架构

分层架构(layered architecture)是最常见的软件架构,也是事实上的标准架构。

这种架构将软件分成若干个水平层,每一层都有清晰的角色和分工,不需要知道其他层的细节。层与层之间通过接口通信。

四层的结构最常见。

  • 表现层(presentation):用户界面,负责视觉和用户互动
  • 业务层(business):实现业务逻辑
  • 持久层(persistence):提供数据,SQL 语句就放在这一层
  • 数据库(database) :保存数据

优点

  • 结构简单,容易理解和开发
  • 不同技能的程序员可以分工,负责不同的层,天然适合大多数软件公司的组织架构
  • 每一层都可以独立测试,其他层的接口通过模拟解决

缺点

  • 一旦环境变化,需要代码调整或增加功能时,通常比较麻烦和费时
  • 部署比较麻烦,即使只修改一个小地方,往往需要整个软件重新部署,不容易做持续发布
  • 软件升级时,可能需要整个服务暂停
  • 扩展性差。用户请求大量增加时,必须依次扩展每一层,由于每一层内部是耦合的,扩展会很困难

事件驱动架构

事件(event)是状态发生变化时,软件发出的通知。

事件驱动架构(event-driven architecture)就是通过事件进行通信的软件架构。它分成四个部分。

  • 事件队列(event queue):接收事件的入口
  • 分发器(event mediator):将不同的事件分发到不同的业务逻辑单元
  • 事件通道(event channel):分发器与处理器之间的联系渠道
  • 事件处理器(event processor):实现业务逻辑,处理完成后会发出事件,触发下一步操作

对于简单的项目,事件队列、分发器和事件通道,可以合为一体,整个软件就分成事件代理和事件处理器两部分。

优点

  • 分布式的异步架构,事件处理器之间高度解耦,软件的扩展性好
  • 适用性广,各种类型的项目都可以用
  • 性能较好,因为事件的异步本质,软件不易产生堵塞
  • 事件处理器可以独立地加载和卸载,容易部署

缺点

  • 涉及异步编程(要考虑远程通信、失去响应等情况),开发相对复杂
  • 难以支持原子性操作,因为事件通过会涉及多个处理器,很难回滚
  • 分布式和异步特性导致这个架构较难测试

微核架构

微核架构(microkernel architecture)又称为”插件架构”(plug-in architecture),指的是软件的内核相对较小,主要功能和业务逻辑都通过插件实现。

内核(core)通常只包含系统运行的最小功能。插件则是互相独立的,插件之间的通信,应该减少到最低,避免出现互相依赖的问题。

优点

  • 良好的功能延伸性(extensibility),需要什么功能,开发一个插件即可
  • 功能之间是隔离的,插件可以独立的加载和卸载,使得它比较容易部署,
  • 可定制性高,适应不同的开发需要
  • 可以渐进式地开发,逐步增加功能

缺点

  • 扩展性(scalability)差,内核通常是一个独立单元,不容易做成分布式
  • 开发难度相对较高,因为涉及到插件与内核的通信,以及内部的插件登记机制

微服务架构

微服务架构(microservices architecture)是服务导向架构(service-oriented architecture,缩写 SOA)的升级。

每一个服务就是一个独立的部署单元(separately deployed unit)。这些单元都是分布式的,互相解耦,通过远程通信协议(比如 REST、SOAP)联系。

微服务架构分成三种实现模式。

  • RESTful API 模式:服务通过 API 提供,云服务就属于这一类
  • RESTful 应用模式:服务通过传统的网络协议或者应用协议提供,背后通常是一个多功能的应用程序,常见于企业内部
  • 集中消息模式:采用消息代理(message broker),可以实现消息队列、负载均衡、统一日志和异常处理,缺点是会出现单点失败,消息代理可能要做成集群

优点

  • 扩展性好,各个服务之间低耦合
  • 容易部署,软件从单一可部署单元,被拆成了多个服务,每个服务都是可部署单元
  • 容易开发,每个组件都可以进行持续集成式的开发,可以做到实时部署,不间断地升级
  • 易于测试,可以单独测试每一个服务

缺点

  • 由于强调互相独立和低耦合,服务可能会拆分得很细。这导致系统依赖大量的微服务,变得很凌乱和笨重,性能也会不佳。
  • 一旦服务之间需要通信(即一个服务要用到另一个服务),整个架构就会变得复杂。典型的例子就是一些通用的 Utility 类,一种解决方案是把它们拷贝到每一个服务中去,用冗余换取架构的简单性。
  • 分布式的本质使得这种架构很难实现原子性操作,交易回滚会比较困难。

云架构

云结构(cloud architecture)主要解决扩展性和并发的问题,是最容易扩展的架构。

它的高扩展性,主要原因是没使用中央数据库,而是把数据都复制到内存中,变成可复制的内存数据单元。然后,业务处理能力封装成一个个处理单元(prcessing unit)。访问量增加,就新建处理单元;访问量减少,就关闭处理单元。由于没有中央数据库,所以扩展性的最大瓶颈消失了。由于每个处理单元的数据都在内存里,最好要进行数据持久化。

这个模式主要分成两部分:处理单元(processing unit)和虚拟中间件(virtualized middleware)。

  • 处理单元:实现业务逻辑
  • 虚拟中间件:负责通信、保持 sessions、数据复制、分布式处理、处理单元的部署。

虚拟中间件又包含四个组件。

  • 消息中间件(Messaging Grid):管理用户请求和 session,当一个请求进来以后,决定分配给哪一个处理单元。
  • 数据中间件(Data Grid):将数据复制到每一个处理单元,即数据同步。保证某个处理单元都得到同样的数据。
  • 处理中间件(Processing Grid):可选,如果一个请求涉及不同类型的处理单元,该中间件负责协调处理单元
  • 部署中间件(Deployment Manager):负责处理单元的启动和关闭,监控负载和响应时间,当负载增加,就新启动处理单元,负载减少,就关闭处理单元。

优点

  • 高负载,高扩展性
  • 动态部署

缺点

  • 实现复杂,成本较高
  • 主要适合网站类应用,不合适大量数据吞吐的大型数据库应用
  • 较难测试

参考资料

系统高性能架构

性能简介

要设计高性能的系统架构,应该有以下的思维步骤:

首先,要明确影响性能的因素有哪些?性能的指标有哪些?——做到有的放矢。

其次,要了解如何测试性能指标?性能优化,必须要有前后的效果对比,才能证明性能确实有改善。

接下来,学习针对不同场景下,不同性指标的优化策略以及具体实施方案。——见招拆招。

计算机资源

了解性能指标前,需要先知道哪些计算机资源会影响性能。一般来说,影响性能的计算机资源包括:

  • CPU
  • 内存
  • 磁盘 I/O
  • 网络 I/O
  • 数据库
  • 锁竞争

性能指标

性能测试的主要指标有:

  • 响应时间
  • 并发数
  • 吞吐量
    • QPS
    • TPS
  • 资源分配使用率

响应时间

响应时间(RT)是指从客户端发一个请求开始计时,到客户端接收到从服务器端返回的响应结果结束所经历的时间,响应时间由请求发送时间、网络传输时间和服务器处理时间三部分组成。

响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。

响应时间可以进一步细分:

  • 客户端响应时间
  • 网络响应时间
  • 服务端响应时间
  • 数据库响应时间

并发数

并发数是指系统能同时处理的请求、事务数

系统自身的 CPU 处理能力、内存、以及系统自身的线程复用、锁竞争等都会影响并发数。

吞吐量

吞吐量计算公式:

1
吞吐量 = 并发数 / 平均响应时间

吞吐量越大,性能越好

一般,系统呈现给外部的最常见的吞吐量指标,就是:

  • QPS(每秒查询数) - 即系统每秒可以处理的读请求。
  • TPS(每秒事务数) - 即系统每秒可以处理的写请求。

而在系统内部,存在以下吞吐量:

  • 磁盘吞吐量 - 体现了磁盘随机读写的性能。
  • 网络吞吐量 - 除了受限于网络带宽,CPU 的处理能力、网卡、防火墙、外部接口以及 I/O、系统 IO 算法都会影响到网络吞吐量。

资源分配使用率

通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 、对象与线程数来表示资源使用率。这些指标也是系统监控的重要参数。

性能测试

性能测试手段:

  • 性能测试
  • 负载测试
  • 压力测试
  • 稳定性测试

对于 Java 应用而言,最简单的,可以使用 Jmeter 进行性能测试。

性能测试报告示例:

#### 性能测试的问题

性能测试时,需要注意一些问题:

  • 热身问题 - 系统刚开始运行时,自身可能加载缓存,JVM 可能会优化热点代码等,这些行为都可能使得前后有较大的性能差异。所以,性能测试时,应该先跳过一段热身时间,等趋于稳定后,再开始性能测试。
  • 测试结果不稳定 - 性能测试中,有很多不稳定的因素,如环境、网络等,几乎不可能每次都是一样的结果。所以应该多次测试,求平均值。
  • 多 JVM 情况下的影响 - 应尽量避免一台机器部署多个 JVM 的情况。因为任意一个 JVM 都拥有整个系统的资源使用权,所以在性能测试时,可能会彼此干扰。

性能优化策略

  1. 性能分析 - 如果请求响应慢,存在性能问题。需要对请求经历的各个环节逐一分析,排查可能出现性能瓶颈的地方,定位问题。检查监控数据,分析影响性能的主要因素:内存、磁盘、网络、CPU,可能是代码或架构设计不合理,又或者是系统资源确实不足。
  2. 性能优化 - 性能优化根据网站分层架构,大致可分为前端性能优化、应用服务性能优化、存储服务性能优化。

应用服务性能优化

缓存

网站性能优化第一定律:第一优先考虑使用缓存提升性能

缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。

  • 单点应用可以使用进程内缓存(如:ConcurrentHashMap、Caffeine);
  • 分布式应用可以使用分布式缓存(如:Redis、Memcached),或进程缓存+分布式缓存的多级缓存方案。

缓存解决方案请参考:缓存基本原理

并发模型

高并发需要根据两个条件划分:连接数量,请求数量。

  • 海量连接(成千上万)海量请求:例如抢购,双十一等
  • 常量连接(几十上百)海量请求:例如中间件
  • 海量连接常量请求:例如门户网站
  • 常量连接常量请求:例如内部运营系统,管理系统

单服务器高性能的关键之一就是服务器采取的并发模型

  • 服务器如何管理连接。
  • 服务器如何处理请求。

以上两个设计点最终都和操作系统的 I/O 模型及进程模型相关。

  • I/O 模型:阻塞、非阻塞、同步、异步。
  • 进程模型:单进程、多进程、多线程。

PPC

PPC 是 Process Per Connection 的缩写,其含义是指每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的 UNIX 网络服务器所采用的模型。基本的流程图是:

img

  • 父进程接受连接(图中 accept)。
  • 父进程“fork”子进程(图中 fork)。
  • 子进程处理连接的读写请求(图中子进程 read、业务处理、write)。
  • 子进程关闭连接(图中子进程中的 close)。

这种模式的缺点:

  • fork 代价高
  • 父子进程通信复杂
  • 支持的并发连接数量有限

prefork

PPC 模式中,当连接进来时才 fork 新进程来处理连接请求,由于 fork 进程代价高,用户访问时可能感觉比较慢,prefork 模式的出现就是为了解决这个问题。

顾名思义,prefork 就是提前创建进程(pre-fork)。系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去 fork 进程的操作,让用户访问更快、体验更好。prefork 的基本示意图是:

img

prefork 的实现关键就是多个子进程都 accept 同一个 socket,当有新的连接进入时,操作系统保证只有一个进程能最后 accept 成功。但这里也存在一个小小的问题:“惊群”现象,就是指虽然只有一个子进程能 accept 成功,但所有阻塞在 accept 上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换了。幸运的是,操作系统可以解决这个问题,例如 Linux 2.6 版本后内核已经解决了 accept 惊群问题。

prefork 模式和 PPC 一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。Apache 服务器提供了 MPM prefork 模式,推荐在需要可靠性或者与旧软件兼容的站点时采用这种模式,默认情况下最大支持 256 个并发连接。

TPC

TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。

TPC 的基本流程是:

img

  • 父进程接受连接(图中 accept)。
  • 父进程创建子线程(图中 pthread)。
  • 子线程处理连接的读写请求(图中子线程 read、业务处理、write)。
  • 子线程关闭连接(图中子线程中的 close)。

注意,和 PPC 相比,主进程不用“close”连接了。原因是在于子线程是共享主进程的进程空间的,连接的文件描述符并没有被复制,因此只需要一次 close 即可。

TPC 虽然解决了 fork 代价高和进程通信复杂的问题,但是也引入了新的问题,具体表现在:

  • 创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题。
  • 无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
  • 多线程会出现互相影响的情况,某个线程出现异常时,可能导致整个进程退出(例如内存越界)。

除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。因此,TPC 方案本质上和 PPC 方案基本类似,在并发几百连接的场景下,反而更多地是采用 PPC 的方案,因为 PPC 方案不会有死锁的风险,也不会多进程互相影响,稳定性更高。

prethread

TPC 模式中,当连接进来时才创建新的线程来处理连接请求,虽然创建线程比创建进程要更加轻量级,但还是有一定的代价,而 prethread 模式就是为了解决这个问题。

和 prefork 类似,prethread 模式会预先创建线程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。

由于多线程之间数据共享和通信比较方便,因此实际上 prethread 的实现方式相比 prefork 要灵活一些,常见的实现方式有下面几种:

  • 主进程 accept,然后将连接交给某个线程处理。
  • 子线程都尝试去 accept,最终只有一个线程 accept 成功,方案的基本示意图如下:

img

Apache 服务器的 MPM worker 模式本质上就是一种 prethread 方案,但稍微做了改进。Apache 服务器会首先创建多个进程,每个进程里面再创建多个线程,这样做主要是为了考虑稳定性,即:即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续提供服务,不会导致整个服务器全部挂掉。

prethread 理论上可以比 prefork 支持更多的并发连接,Apache 服务器 MPM worker 模式默认支持 16 × 25 = 400 个并发处理线程。

Reactor

I/O 多路复用技术归纳起来有两个关键实现点:

  • 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 selectepollkqueue 等。
  • 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。

I/O 多路复用结合线程池,完美地解决了 PPC 和 TPC 的问题

Reactor 模式的核心组成部分包括 Reactor 和处理资源池(进程池或线程池),其中 Reactor 负责监听和分配事件,处理资源池负责处理事件。初看 Reactor 的实现是比较简单的,但实际上结合不同的业务场景,Reactor 模式的具体实现方案灵活多变,主要体现在:

  • Reactor 的数量可以变化:可以是一个 Reactor,也可以是多个 Reactor。
  • 资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程(线程类似)。

最终 Reactor 模式有这三种典型的实现方案:

  • 单 Reactor 单进程 / 线程。
  • 单 Reactor 多线程。
  • 多 Reactor 多进程 / 线程。

异步操作

异步处理不仅可以减少系统服务间的耦合度,提高扩展性,事实上,它还可以提高系统的性能。异步处理可以有效减少响应等待时间,从而提高响应速度。

异步处理一般是通过分布式消息队列的方式。

异步处理可以解决以下问题:

  • 异步响应
  • 应用解耦
  • 流量削锋
  • 日志处理
  • 消息通讯

负载均衡

在高并发场景下,使用负载均衡技术为一个应用构建一个由多台服务器组成的服务器集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,使用户请求具有更好的响应延迟特性。

高性能集群的复杂性主要体现在需要增加一个任务分配器,以及为任务选择一个合适的任务分配算法

缓存解决方案请参考:负载均衡

代码优化

多线程

从资源利用的角度看,使用多线程的原因主要有两个:IO 阻塞和多 CPU。

线程数并非越多越好,那么启动多少线程合适呢?

有个参考公式:

1
启动线程数 = (任务执行时间 / (任务执行时间 - IO 等待时间)) * CPU 内核数

最佳启动线程数和 CPU 内核数成正比,和 IO 阻塞时间成反比。

  • 如果任务都是 CPU 计算型任务,那么线程数最多不要超过 CPU 内核数,因为启动再多线程,CPU 也来不及调度;
  • 相反,如果是任务需要等待磁盘操作,网络响应,那么多启动线程有助于任务并发,提高系统吞吐量。
线程安全问题

线程安全问题时指多个线程并发访问某个资源,导致数据混乱。

解决手段有:

  • 将对象设计为无状态对象 - 典型应用:Servlet 就是无状态对象,可以被服务器多线程并发调用处理用户请求。
  • 使用局部对象
  • 并发访问资源时使用锁 - 但是引入锁会产生性能开销,应尽量使用轻量级的锁。

资源复用

应该尽量减少那些开销很大的系统资源的创建和销毁,如数据库连接、网络通信连接、线程、复杂对象等。从编程角度,资源复用主要有两种模式:单例模式和对象池。

数据结构

根据具体场景,选择合适的数据结构。

垃圾回收

如果 Web 应用运行在 JVM 等具有垃圾回收功能的环境中,那么垃圾回收可能会对系统的性能特性产生巨大影响。立即垃圾回收机制有助于程序优化和参数调优,以及编写内存安全的代码。

存储性能优化

数据库

数据库读写分离

读写分离的基本原理是将数据库读写操作分散到不同的节点上

详细解决方案参考:读写分离

数据库分库分表

数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果

详细解决方案参考:分库分表

Nosql

关系型数据库的优势在于:存储结构化数据,有利于进行各种复杂查询。

但是,它也存在一些缺点:

  • 关系数据库存储的是行记录,无法存储数据结构
  • 关系数据库的 schema 扩展很不方便
  • 关系数据库在大数据场景下 I/O 较高
  • 关系数据库的全文搜索功能比较弱

为了解决上述问题,分别诞生了解决不同问题的 Nosql 数据库。

常见的 NoSQL 数据库可以分为四类:

  • K-V 数据库:KV 存储非常适合存储不涉及过多数据关系业务关系的数据,同时能有效减少读写磁盘的次数,比 SQL 数据库存储拥有更好的读写性能,能够解决关系型数据库无法存储数据结构的问题。以 Redis 为代表。
  • 列式数据库适合于批量数据处理和即时查询,解决关系数据库大数据场景下的 I/O 问题。以 HBase 为代表。
  • 文档数据库:文档数据库(也称为文档型数据库)是旨在将半结构化数据存储为文档的一种数据库,它可以解决关系型数据库表结构 schema 扩展不方便的问题。文档数据库通常以 JSON 或 XML 格式存储数据。以 MongoDB 为代表。
  • 全文搜索引擎解决关系型数据库全文搜索功能较弱的问题。以 Elasticsearch 为代表。

详情参考:Nosql 技术选型

文件存储

机械键盘和固态硬盘

考虑使用固态硬盘替代机械键盘,因为它的读写速度更快。

B+数和 LSM 树

传统关系数据库的数据库索引一般都使用两级索引的 B+ 树 结构,树的层次最多三层。因此可能需要 5 次磁盘访问才能更新一条记录(三次磁盘访问获得数据索引及行 ID,然后再进行一次数据文件读操作及一次数据文件写操作)。

由于磁盘访问是随机的,传统机械键盘在数据随机访问时性能较差,每次数据访问都需要多次访问磁盘影响数据访问性能。

许多 Nosql 数据库中的索引采用 LSM 树 作为主要数据结构。LSM 树可视为一个 N 阶合并树。数据写操作都在内存中进行。在 LSM 树上进行一次数据更新不需要磁盘访问,速度远快于 B+ 树

RAID 和 HDFS

RAID 是 Redundant Array of Independent Disks 的缩写,中文简称为独立冗余磁盘阵列。

RAID 是一种把多块独立的硬盘(物理硬盘)按不同的方式组合起来形成一个硬盘组(逻辑硬盘),从而提供比单个硬盘更高的存储性能和提供数据备份技术。

HDFS(分布式文件系统) 更被大型网站所青睐。它可以配合 MapReduce 并发计算任务框架进行大数据处理,可以在整个集群上并发访问所有磁盘,无需 RAID 支持。

HDFS 对数据存储空间的管理以数据块(Block)为单位,默认为 64 MB。所以,HDFS 更适合存储较大的文件。

前端性能优化

浏览器访问优化

  1. 减少 HTTP 请求 - HTTP 请求需要建立通信链路,进行数据传输,开销高昂,所以减少 HTTP 请求数可以有效提高访问性能。减少 HTTP 的主要手段是合并 Css、JavaScript、图片。
  2. 使用浏览器缓存 - 因为静态资源文件更新频率低,可以缓存浏览器中以提高性能。设置 HTTP 头中的 Cache-ControlExpires 属性,可设定浏览器缓存。
  3. 启用压缩 - 在服务器端压缩静态资源文件,在浏览器端解压缩,可以有效减少传输的数据量。由于文本文件压缩率可达 80% 以上,所以可以对静态资源,如 Html、Css、JavaScrip 进行压缩。
  4. CSS 放在页面最上面,JavaScript 放在页面最下面 - 浏览器会在下载完全部的 Css 后才对整个页面进行渲染,所以最好的做法是将 Css 放在页面最上面,让浏览器尽快下载 Css;JavaScript 则相反,浏览器加载 JavaScript 后立即执行,可能会阻塞整个页面,造成页面显示缓慢,因此 JavaScript 最好放在页面最下面。
  5. 减少 Cookie 传输 - Cookie 包含在 HTTP 每次的请求和响应中,太大的 Cookie 会严重影响数据传输。

CDN

CDN 一般缓存的是静态资源。

CDN 的本质仍然是一个缓存,而且将数据缓存在离用户最近的地方,使用户已最快速度获取数据,即所谓网络访问第一跳。

反向代理

传统代理服务器位于浏览器一侧,代理浏览器将 HTTP 请求发送到互联网上,而反向代理服务器位于网站机房一侧,代理网站服务器接收 HTTP 请求。

反向代理服务器可以配置缓存功能加速 Web 请求,当用户第一次访问静态内容时,静态内容就会被缓存在反向代理服务器上。

反向代理还可以实现负载均衡,通过负载均衡构建的集群可以提高系统总体处理能力。

因为所有请求都必须先经过反向代理服务器,所以可以屏蔽一些攻击 IP,达到保护网站安全的作用。

参考资料

系统高可用架构

高可用架构简介

系统可用性的度量

系统不可用也被称作系统故障,业界通常用多个 9 来衡量系统的可用性。如 QQ 的可用性为 4 个 9,即 99.99% 可用。

1
2
网站不可用时间 = 故障修复时间点 - 故障发现时间点
网站年度可用性指标 = (1 - 网站不可用时间/年度总时间) * 100%

可用性计量表:

可用性级别 系统可用性% 宕机时间/年 宕机时间/月 宕机时间/周 宕机时间/天
不可用 90% 36.5 天 73 小时 16.8 小时 144 分钟
基本可用 99% 87.6 小时 7.3 小时 1.68 小时 14.4 分钟
较高可用 99.9% 8.76 小时 43.8 分钟 10.1 分钟 1.44 分钟
高可用 99.99% 52.56 分钟 4.38 分钟 1.01 秒 8.64 秒
极高可用 99.999% 5.26 分钟 26.28 秒 6.06 秒 0.86 秒

故障原因

系统宕机原因主要有以下:

无计划的

  • 系统级故障,包括主机、操作系统、中间件、数据库、网络、电源以及外围设备。
  • 数据和中介的故障,包括人员误操作、硬盘故障、数据乱了。
  • 还有自然灾害、人为破坏,以及供电问题等。

有计划的

  • 日常任务:备份,容量规划,用户和安全管理,后台批处理应用。
  • 运维相关:数据库维护、应用维护、中间件维护、操作系统维护、网络维护。
  • 升级相关:数据库、应用、中间件、操作系统、网络,包括硬件升级。

我们再给它们归个类。

  1. 网络问题。网络链接出现问题,网络带宽出现拥塞……
  2. 性能问题。数据库慢 SQL、Java Full GC、硬盘 IO 过大、CPU 飙高、内存不足……
  3. 安全问题。被网络攻击,如 DDoS 等。
  4. 运维问题。系统总是在被更新和修改,架构也在不断地被调整,监控问题……
  5. 管理问题。没有梳理出关键服务以及服务的依赖关系,运行信息没有和控制系统同步……
  6. 硬件问题。硬盘损坏、网卡出问题、交换机出问题、机房掉电、挖掘机问题……

什么是高可用的系统架构

通常,企业级应用系统为提高系统可用性,会采用较昂贵的软硬件设备,当然这样的设备也比较稳定。

互联网公司或一些初创型公司基于成本考虑,更多采用 PC 级软硬件设备,节约成本所付出的代价就是设备较为不稳定。服务器一年中出现几次宕机,高强度读写磁盘导致磁盘损坏等事件实属正常。

综上,硬件出现故障应视为必然的,而高可用的系统架构设计目标就是要保证当出现硬件故障时,服务依然可用,数据依然能够保存并被访问实现高可用的系统架构的主要手段是数据和服务的冗余备份及失效转移,一旦某些服务器宕机,就将服务切换到其他可用的服务器上;如果磁盘损坏,则从备份的磁盘读取数据。

大型系统的分层架构及物理服务器的分布式部署使得位于不同层次的服务器具有不同的可用性特点。关闭服务或服务器宕机时产生的影响也不相同,高可用的解决方案也差异甚大。大致可以分为:

  • 高可用的应用 - 主要手段是:负载均衡
  • 高可用的服务 - 主要手段是:分级管理、超时重试、异步调用、限流、降解、断路、幂等性设计
  • 高可用的数据 - 主要手段是:数据备份和失效转移

高可用架构理论

学习高可用架构,首先需要了解分布式基础理论:CAP 和 BASE。

然后,很多著名的分布式系统,都利用选举机制,来保证主节点宕机时的故障恢复。如果要深入理解选举机制,有必要了解:Paxos 算法Raft 算法。Paxos 和 Raft 是为了实现分布式系统中高可用架构而提出的共识性算法,已经成为业界标准。

CAP 定理又称为 CAP 原则,指的是:在一个分布式系统中, 一致性(C:Consistency)可用性(A:Availability)分区容忍性(P:Partition Tolerance),最多只能同时满足其中两项

BASE 是 基本可用(Basically Available)软状态(Soft State)最终一致性(Eventually Consistent) 三个短语的缩写。BASE 理论是对 CAP 中一致性和可用性权衡的结果,它的理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

CAP 和 BASE 理论的详细说明请参考:分布式一致性

Paxos 和 Raft 的详细说明请参考:Paxos 算法Raft 算法

架构模式

主备复制

主备复制是最常见也是最简单的一种存储高可用方案,几乎所有的存储系统都提供了主备复制的功能,例如 MySQL、Redis、MongoDB 等。

主备复制要点:

  • 存在一主多备
  • 主机负责读&写,并定期复制数据给备机。
  • 一旦主机宕机,可以通过人工手段,将其中一个备节点作为主节点。

img

优点

  • 主备复制架构中,客户端可以不感知备机的存在。即使灾难恢复后,原来的备机被人工修改为主机后,对于客户端来说,只是认为主机的地址换了而已,无须知道是原来的备机升级为主机。
  • 主备复制架构中,主机和备机之间,只需要进行数据复制即可,无须进行状态判断和主备切换这类复杂的操作。

缺点

  • 主备复制架构中,故障后需要人工干预,无法自动恢复。

适用场景

综合主备复制架构的优缺点,内部的后台管理系统使用主备复制架构的情况会比较多,例如学生管理系统、员工管理系统、假期管理系统等,因为这类系统的数据变更频率低,即使在某些场景下丢失数据,也可以通过人工的方式补全。

主从复制

主从复制和主备复制只有一字之差,区别在于:主从复制模式中,从机要承担读操作

主从复制要点:

  • 存在一主多从
  • 主机负责读&写,并定期复制数据给从机。
  • 从机只负责读。
  • 一旦主机宕机,可以通过人工手段,将其中一个从节点作为主节点。

img

优点

  • 主从复制架构中,主机故障时,读操作相关的业务可以继续运行。
  • 主从复制架构中,从机提供读操作,发挥了硬件的性能。

缺点

  • 主从复制架构中,客户端需要感知主从关系,并将不同的操作发给不同的机器进行处理,复杂度比主备复制要高。
  • 主从复制架构中,从机提供读业务,如果主从复制延迟比较大,业务会因为数据不一致出现问题。
  • 主从复制架构中,故障时需要人工干预。

适用场景

综合主从复制的优缺点,一般情况下,写少读多的业务使用主从复制的存储架构比较多。例如,论坛、BBS、新闻网站这类业务,此类业务的读操作数量是写操作数量的 10 倍甚至 100 倍以上。

集群+分区

在主备复制和主从复制模式中,都由一个共性问题:

每个机器上存储的都是全量数据。但是,单机的数据存储量总是有上限的,当数据量上升为 TB 级甚至 PB 级数据,单机终究有无法支撑的时候。这时,就需要对数据进行分片(sharding)。

分片后的节点可以视为一个独立的子集,针对子集,任然需要保证高可用。

img

高可用的应用

应用层主要处理网站应用的业务逻辑,一个显著的特点是应用的 无状态 性。

所谓的 无状态 的应用是指应用服务器不保存业务的上下文信息,而仅根据每次请求提交的数据进行相应的业务逻辑处理,多个服务实例之间完全对等,请求提交到任意服务器,处理结果都是完全一样的。

由于无状态应用,各实例之间不用考虑数据一致性问题,所以其高可用方案相对简单。主要手段是:

  • 负载均衡
  • 分布式 Session

负载均衡

负载均衡,顾名思义,主要使用在业务量和数据量较高的情况下,当单台服务器不足以承担所有的负载压力时,通过负载均衡手段,将流量和数据分摊到一个集群组成的多台服务器上,以提高整体的负载处理能力。

无状态应用的失效转移可以利用负载均衡来实现

无状态的应用实现高可用架构十分简单,由于服务器不保存请求状态,那么所有服务器完全对等,在任意节点执行同样的请求,结果总是一致的。这种情况下,最简单的高可用方案就是使用负载均衡。

负载均衡原理可以参考:负载均衡基本原理

分布式 Session

应用服务器的高可用架构设计主要基于服务无状态这一特性。事实上,业务总是有状态的,如购物车记录用户的购买信息;用户的登录状态;最新发布的消息等等。

在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。

为了解决分布式 Session 问题,常见的解决方案有:

  • 粘性 session
  • 应用服务器间的 session 复制共享
  • 基于缓存的 session 共享 ✅

分布式会话原理可以参考:分布式会话基本原理

高可用的服务

可复用的服务为业务产品提供基础公共服务,大型系统中这些服务通常都独立分布式部署,被具体应用远程调用。可复用的服务和应用一样,一般也是无状态的服务,因此,同样可以使用负载均衡的失效转移策略来实现高可用

除此以外,还有以下手段来保证服务的高可用:

  • 分级管理
  • 超时重试
  • 异步调用
  • 过载保护
    • 限流
    • 降级
    • 断路
  • 幂等性设计

分级管理

将服务根据业务重要性进行分级管理,核心应用和服务优先使用更好的硬件,在运维响应速度上也格外迅速。

在服务部署上进行必要的隔离,避免故障的连锁反应。低优先级的服务通过启动不同的线程或部署在不同的虚拟机上进行隔离,而高优先级的服务则需要部署在不同的物理机上,核心服务和数据甚至要部署在不同地域的数据中心。

超时重试

由于服务器宕机、线程死锁等原因,可能导致应用程序对服务端的调用失去响应。所以有必要引入超时机制,一旦调用超时,服务化框架抛出异常,应用程序根据服务调度策略,选择重试或请求转移到其他机器上。

异步调用

对于需要即时响应的业务,应用在调用服务时可以通过消息队列等异步方式完成,避免一个服务失败导致整个应用请求失败的情况。当然不是所有服务调用都可以异步调用,对于获取用户信息这类调用,采用异步方式会延长响应时间,得不偿失;此外,对于那些必须确认服务调用才能继续下一步操作的应用也不适宜食用异步调用。

过载保护

过载保护的手段,一般有:限流、降级、熔断。

限流

降级是从系统功能优先级的角度考虑如何应对故障,而限流则是从用户访问压力的角度来考虑如何应对故障。限流指只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃。

常见的限流方式可以分为两类:基于请求限流和基于资源限流。

基于请求限流

基于请求限流指从外部访问的请求角度考虑限流,常见的方式有:限制总量、限制时间量。

限制总量的方式是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量,例如某个直播间限制总用户数上限为 100 万,超过 100 万后新的用户无法进入;某个抢购活动商品数量只有 100 个,限制参与抢购的用户上限为 1 万个,1 万以后的用户直接拒绝。限制时间量指限制一段时间内某个指标的上限,例如,1 分钟内只允许 10000 个用户访问,每秒请求峰值最高为 10 万。

无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值。

基于资源限流

基于请求限流是从系统外部考虑的,而基于资源限流是从系统内部考虑的,即:找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源有:连接数、文件句柄、线程数、请求队列等。

基于资源限流相比基于请求限流能够更加有效地反映当前系统的压力,但实践中设计也面临两个主要的难点:如何确定关键资源,如何确定关键资源的阈值。

降级

降级指系统将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。

在服务访问的高峰期,服务可能因为大量并发调用而性能下降,严重时可能会导致宕机。为了保证核心功能的正常运行,需要对服务进行降级。降级有两种手段:

拒绝服务 - 拒绝低优先级应用的调用,减少服务调用并发数,确保核心应用正常使用。或者随机拒绝部分调用,节约资源,避免要死大家一起死的惨剧。

关闭服务 - 关闭部分不重要的服务,或者服务内部关闭部分不重要的功能,以节约资源。

熔断

熔断和降级是两个比较容易混淆的概念,因为单纯从名字上看好像都有禁止某个功能的意思,但其实内在含义是不同的,原因在于降级的目的是应对系统自身的故障,而熔断的目的是应对依赖的外部系统故障的情况。

熔断机制实现的关键是需要有一个统一的 API 调用层,由 API 调用层来进行采样或者统计,如果接口调用散落在代码各处就没法进行统一处理了。

幂等性设计

服务调用失败后,调用方会将请求转发到其他服务器上,但是这个失败可能是虚假的失败。比如服务已经处理成功,但因为网络故障导致调用方没有收到应答,或等待超时。这种情况下,重新发起请求,可能会导致重复操作,如:向数据库写入两条记录。如果这个操作是比较敏感的交易操作,就会产生严重后果。

服务重复调用时无法避免的,但是只要能从业务实现上保证,重复调用和一次调用的处理结果一致,则业务就没有问题,这就是幂等性设计。

有些服务的业务天然具有幂等性,比如将用户性别设为男性,不管执行多少次,结果是一致的。但有些复杂的业务,要想保证幂等性,就需要根据全局性的 ID 去进行有效性验证,验证通过才能继续执行。

高可用的存储

对于绝大部分软件系统而言,数据都是最宝贵的虚拟资产,一旦丢失,可以说是毁灭性的打击。

保证存储高可用的主要手段是:数据备份失效转移

存储高可用架构的复杂性主要体现在:如何应对副本同步延迟和中断导致的数据一致性问题

提示:再开始学习这部分内容前,建议先学习 二、高可用架构理论

数据备份

数据备份是保证数据有多个副本,任意副本的丢失都不会导致数据的永久丢失。

  • 冷备份 - 定期将数据复制到某种存储介质。
  • 热备份
    • 异步热备方式 - 异步热备方式是指多份数据副本的写入操作异步完成,应用程序收到数据服务系统的写操作成功响应时,只写成功了一份,存储系统将会异步地写其他副本。
    • 同步热备方式 - 同步热备方式是指多份数据副本的写入操作同步完成,即应用程序收到数据服务系统的写成功响应时,多份数据都已经写操作成功。但是当应用程序收到数据写操作失败的响应式,可能有部分副本或者全部副本都已经写入成功了(因为网络或者系统故障,无法返回操作成功的响应)。

失效转移

失效转移是保证任意一个副本不可访问时,可以快速切换访问其他副本,保证系统整体可用。

失效确认

判断服务器宕机的手段有两种:心跳检测访问失败报告

对于应用程序的访问失败报告,控制中心还需要再一次发送心跳检测进行确认,以免错误判断服务器宕机。因为一旦进行数据访问的失效转移,意味着数据存储多份副本不一致,需要进行后续一系列的复杂动作。

访问转移

确认某台数据服务器宕机后,就需要将数据读写访问重新路由到其他服务器上。对于完全对等存储的服务器,当其中一台宕机后,应用程序根据配置直接切换到对等服务器上。如果存储不对等,就需要重新计算路由,选择存储服务器。

数据恢复

因为某台服务器宕机,所以数据存储的副本数目会减少,必须将副本的数目恢复到系统设定的值,否则,再有服务器宕机时,就可能出现无法访问转移,数据永久丢失的情况。因此系统需要从健康的服务器复制数据,将数据副本数目恢复到设定值。

辅助手段

异地多活

异地多活架构的关键点就是异地、多活,其中异地就是指地理位置上不同的地方,类似于“不要把鸡蛋都放在同一篮子里”;多活就是指不同地理位置上的系统都能够提供业务服务,这里的“活”是活动、活跃的意思。

异地多活架构可以分为同城异区、跨城异地、跨国异地。

异地多活架构的代价:

  • 系统复杂度会发生质的变化,需要设计复杂的异地多活架构。
  • 成本会上升,毕竟要多在一个或者多个机房搭建独立的一套业务系统。

异地多活的设计原则:

  • 保证核心业务的异地多活
  • 保证核心数据最终一致性
  • 采用多种手段同步数据
  • 只保证绝大部分用户的异地多活

异地多活设计步骤:

  • 业务分级 - 常见的分级标准有:
    • 流量大的业务
    • 核心业务
    • 盈利业务
  • 数据分类 - 常见的数据分析维度有:
    • 数据量
    • 唯一性
    • 实时性
    • 可丢实性
    • 可恢复性
  • 数据同步 - 常见的数据同步方案
    • 存储系统同步
    • 消息队列同步
    • 重复生成
  • 异常处理 - 常见异常处理措施:
    • 多通道同步
    • 同步和访问结合
    • 日志记录
    • 用户补偿

发布流程

高可用的软件质量保证的手段:

  • 自动化测试
  • 预发布验证
  • 代码控制
  • 自动化发布
  • 灰度发布

系统监控

不允许没有监控的系统上线。

  • 监控数据采集
    • 用户行为日志收集
      • 服务端日志收集 - Apache、Nginx 等几乎所有 Web 服务器都具备日志记录功能,只要开启日志记录即可。如果是服务器比较多,需要集中采集日志,通常会使用 Elastic 来进行收集。
      • 客户端日志收集 - 利用页面嵌入专门的 JavaScript 脚本可以收集用户真实的操作行为。
      • 日志分析 - 可以利用 ElasticSearch 做语义分析及搜索;利用实时计算框架 Storm、Flink 等开发日志统计与分析工具。
    • 服务器性能监控 - 收集服务器性能指标,如系统负载、内存占用、CPU 占用、磁盘 IO、网络 IO 等。常用的监控工具有:Apache SkyWalkingPinpoint 等。
    • 运行数据报告 - 应该监控一些与具体业务场景相关的技术和业务指标,如:缓存命中率、平均响应时延、TPS、QPS 等。
  • 监控管理
    • 系统报警 - 设置阈值。当达到阈值,及时触发告警(短信、邮件、通信工具均可),通过及时判断状况,防患于未然。
    • 失效转移 - 监控系统可以在发现故障的情况下主动通知应用进行失效转移。
    • 自动优雅降级
      • 优雅降级是为了应付突然爆发的访问高峰,主动关闭部分功能,释放部分资源,以保证核心功能的优先访问。
      • 系统在监控管理基础之上实现自动优雅降级,是柔性架构的理想状态。

参考资料

系统伸缩性架构

伸缩性架构是指不需要改变系统的软硬件设计,仅通过改变部署服务器数量就可以扩大或缩小系统的服务处理能力。

系统架构的伸缩性设计

不同功能进行物理分离实现伸缩

  • 纵向分离(分层后分离) - 将业务处理流程上的不同部分分离部署,实现系统伸缩性。
  • 横向分离(业务分割后分离) - 将不同的业务模块分离部署,实现系统伸缩性。

单一功能通过集群规模实现伸缩

将不同功能分离部署可以实现一定程度的伸缩性,但是随着访问量逐步增加,即使分离到最小粒度的独立部署,单一的服务器也不能满足业务规模的要求。因此必须使用服务器集群,即将相同服务部署在多态服务器上构成一个集群整体对外提供服务。

应用服务器集群的伸缩性设计

如果 HTTP 请求分发装置可以感知或者可以配置集群的服务器数量,可以及时发现集群中新上线或下线的服务器,并能向新上线的服务器分发请求,停止向已下线的服务器分发请求,那么就实现了应用服务器集群的伸缩性。

HTTP 重定向负载均衡

利用 HTTP 重定向协议实现负载均衡。

这种负载均衡方案的优点是比较简单。

缺点是浏览器需要两次请求服务器才能完成一次访问,性能较差:重定向服务器自身的处理能力有可能成为瓶颈,整个集群的伸缩性规模有限;使用 HTTP 302 响应码重定向,可能使搜索引擎判断为 SEO 作弊,降低搜索排名。

DNS 域名解析负载均衡

利用 DNS 处理域名解析请求的同时进行负载均衡处理的一种方案。

在 DNS 服务器中配置多个 A 记录,如:

1
2
3
114.100.40.1 www.mysite.com
114.100.40.2 www.mysite.com
114.100.40.3 www.mysite.com

每次域名解析请求都会根据负载均衡算法计算一个不同的 IP 地址返回,这样 A 记录中配置的多个服务器就构成一个集群,并可以实现负载均衡。

DNS 域名解析负载均衡的优点:

  • 将负载均衡的工作转交给了 DNS,省掉了网站管理维护的麻烦。
  • 同时,许多 DNS 服务器还支持基于地理位置的域名解析,即将域名解析成距离用户地理最近的一个服务器地址,这样可以加快用户访问速度,改善性能。

DNS 域名解析负载均衡的缺点:

  • DNS 是多级解析,每一级 DNS 都可能缓存 A 记录,当某台服务器下线后,即使修改了 DNS 的 A 记录,要使其生效也需要较长时间。这段时间,依然会域名解析到已经下线的服务器,导致用户访问失败。
  • DNS 的负载均衡的控制权在域名服务商那里,网站无法对其做更多改善和更强大的管理。

反向代理负载均衡

大多数反向代理服务器同时提供反向代理和负载均衡的功能。

反向代理服务器的优点是部署简单。缺点是反向代理服务器是所有请求和响应的中转站,其性能可能会成为瓶颈。

IP 负载均衡

在网络层通过修改请求目标地址进行负载均衡。

负载均衡服务器(网关服务器)在操作系统内核获取网络数据包,根据负载均衡算法计算得到一台真实 Web 服务器 10.0.0.1,然后将目的 IP 地址修改为 10.0.0.1,不需要通过用户进程。真实 Web 服务器处理完成后,响应数据包回到负载均衡服务器,负载均衡服务器再将数据包原地址修改为自身的 IP 地址(114.100.80.10)发送给浏览器。

IP 负载均衡在内核完成数据分发,所以处理性能优于反向代理负载均衡。但是因为所有请求响应都要经过负载均衡服务器,集群的最大响应数据吞吐量受制于负载均衡服务器网卡带宽。

数据链路层负载均衡

数据链路层负载均衡是指在通信协议的数据链路层修改 mac 地址进行负载均衡。

这种方式又称作三角传输方式,负载均衡数据分发过程中不修改 IP 地址,只修改目的 mac 地址,通过配置真实物理服务器集群所有机器虚拟 IP 和负载均衡服务器 IP 地址一致,从而达到不修改数据包的源地址和目的地址就可以进行数据分发的目的,由于实际处理请求的真实物理服务器 IP 和数据请求目的 IP 一致,不需要通过负载均衡服务器进行地址转换,可将响应数据包直接返回给用户浏览器,避免负载均衡服务器网卡带宽成为瓶颈。这种负载方式又称作直接路由方式。

在 Linux 平台上最好的链路层负载均衡开源产品是 **LVS(Linux Virtual Server)**。

负载均衡算法

负载均衡服务器的实现可以分为两个部分:

  1. 根据负载均衡算法和 Web 服务器列表计算得到集群中一台 Web 服务器的地址。
  2. 将请求数据发送到该地址对应的 Web 服务器上。

负载均衡算法通常有以下几种:

  • 轮询(Round Robin) - 所有请求被依次分发到每台应用服务器上,即每台服务器需要处理的请求数据都相同,适合于所有服务器硬件都相同的场景。
  • 加权轮询(Weighted Round Robin) - 根据服务器硬件性能情况,在轮询的基础上,按照配置权重将请求分发到每个服务器,高性能服务器能分配更多请求。
  • 随机(Random) - 请求被随机分配到各个应用服务器,在许多场合下,这种方案都很简单实用,因为好的随机数本身就很平均,即使应用服务器硬件配置不同,也可以使用加权随机算法。
  • 最少连接(Least Connection) - 记录每个应用服务器正在处理的连接数,将新到的请求分发到最少连接的服务器上,应该说,这是最符合负载均衡定义的算法。
  • 源地址 Hash(Source Hash) - 根据请求来源的 IP 地址进行 Hash 计算,得到应用服务器,这样来自同一个 IP 地址的请求总在同一个服务器上处理,该请求的上下文信息可以存储在这台服务器上,在一个会话周期内重复使用,从而实现会话粘滞。

分布式缓存集群的伸缩性设计

目前比较流行的分布式集群伸缩性方案就是:一致性 HASH 算法

数据存储服务集群的伸缩性设计

关系型数据库的伸缩性设计

  • 主从复制 - 主流关系型数据库一般都支持主从复制。
  • 分库 - 根据业务对数据库进行分割。制约条件是跨库的表不能进行 Join 操作。
  • 分表 - 使用数据库分片中间件,如 Cobar 等。

NoSql 数据库的伸缩性设计

一般而言,Nosql 不支持 SQL 和 ACID,但是强化了对于高可用和伸缩性的支持。

参考资料