GO语言调试利器dlv快速上手

https://www.cnblogs.com/realjimmy/p/13418508.html

一、dlv的安装

1)下载dlv

git clone https://github.com/go-delve/delve.git $GOPATH/src/github.com/go-delve/delve

或者 go get github.com/derekparker/delve/cmd/dlv

2)安装

cd $GOPATH/src/github.com/go-delve/delve

make install

二、dlv简要使用说明

2.1、获取帮助信息

安装后执行dlv -h将会看到帮助信息:

image

上面的信息只是列出了命令列表,具体使用方法没有给出,我们可以执行dlv help + 具体命令来查看详细说明,

比如我们执行dlv help attach:

image

2.2、进入调试模式

1)dlv attach pid:类似与gdb attach pid,可以对正在运行的进程直接进行调试(pid为进程号)。

2)dlv debug:运行dlv debug test.go会先编译go源文件,同时执行attach命令进入调试模式,该命令会在当前目录下生成一个名为debug的可执行二进制文件,退出调试模式会自动被删除。

3)dlv exec executable_file :直接从二进制文件启动调试模式。如果要带参数执行需要添加–,如dlv exec executable_file — -f xxx.conf

4)dlv core executable_file core_file:以core文件启动调试,通常进行dlv的目的就是为了找出可执行文件core的原因,通过core文件可直接找出具体进程异常的信息。

3、常用调试方法

3.1 dlv trace追踪调用轨迹

该命令最直接的用途是可以追踪代码里函数的调用轨迹,

如下源代码,现用trace命令跟踪其调轨迹。

package main import ( “fmt” “time” ) func Test() { fmt.Println(“hello”) time.Sleep(1000 * 1000 * 100) } func Test2() { fmt.Println(“world”) time.Sleep(1000 * 1000 * 100) } func main() { for i := 0; i < 2; i++ { go Test() go Test2() } time.Sleep(1000 * 1000 * 2000) fmt.Println(“end”) }

运行结果,这里看除了Test,test2也被追踪:

$ dlv trace hello.go Test

> goroutine(19): main.Test2()

> goroutine(21): main.Test2()

> goroutine(18): main.Test()

world

hello

world

> goroutine(20): main.Test()

hello

=> ()

=> ()

=> ()

=> ()

end

3.2 调试模式基本命令

这里用上节的源码作为示例进行调试。开始调试:dlv debug hello.go

1)b(break):打断点

设置断点,当需要设置多个断点时,为了断点可识别可进行自定义命名。进入调试模式后先打断点。

例:b Test

b test.go:13

image

2)r(restart):重启当前进程

类似gdb里的run,如果刚执行dlv debug hello.go,进程已经起来,不用执行。如果进程已结算或需要重新开始则需要执行r

3)c(continue):继续执行到断点处

image

4)bp:查看所有断点

image

5)on  :当运行到某断点时执行相应命令

断点可以是名称(在设置断点时可命名断点)或者编号,例如on 3 p i表示运行到断点3时打印变量i。

image

6)cond(condition)   :有条件的断点

针对某个断点,只有表达式成立才会被中断。例:

condition 3 i==1

image

image

7)n(next):逐行执行代码,不进入函数内

8)s(step):逐行执行代码,遇到函数会跳进内部

9)stepout:当使用s命令进入某个函数后,执行它可跳出函数

10)si(step-instruction):单步单核执行代码

如果不希望多协程并发执行可以使用该命令,这在多协程调试时极为方便。

11)args:查看被调用函数所传入的参数值

12)locals:查看所有局部变量

locals var_name:查看具体某个变量,var_name可以是正则表达式。

13)clear:清除单个断点

14)clearall:清除所有断点

15)list:打印当前断点位置的源代码

list后面加行号可以展示该行附近的源代码,要注意该行必须是代码行而不能是空行。

16)bt:打印当前栈信息。

3.3 多协程调试

1)goroutines:显示所有协程

image

2)goroutine:协程切换

先执行goroutine 7表示切换到7号协程上

3.4 其他命令

1)frame:切换栈。

2)regs:打印寄存器内容。

3)sources:打印所有源代码文件路径

4)source:执行一个含有dlv命令的文件

source命令允许将dlv命令放在一个文件中,然后逐行执行文件内的命令。

5)trace:类似于打断点,但不会中断,同时会输出一行提示信息

浅析控制反转

https://zhuanlan.zhihu.com/p/60995312

介绍

控制反转 (Inversion of control) 并不是一项新的技术,是 Martin Fowler 教授提出的一种软件设计模式。那到底什么被反转了?获得依赖对象的过程被反转了。控制反转 (下文统一简称为 IoC) 把传统模式中需要自己通过 new 实例化构造函数,或者通过工厂模式实例化的任务交给容器。通俗的来理解,就是本来当需要某个类(构造函数)的某个方法时,自己需要主动实例化变为被动,不需要再考虑如何实例化其他依赖的类,只需要依赖注入 (Dependency Injection, 下文统一简称为 DI), DI 是 IoC 的一种实现方式。所谓依赖注入就是由 IoC 容器在运行期间,动态地将某种依赖关系注入到对象之中。所以 IoC 和 DI 是从不同的角度的描述的同一件事情,就是通过引入 IoC 容器,利用依赖注入的方式,实现对象之间的解耦。

那反转控制这种设计模式到底给前端带来了什么价值?这里先给出答案:

  1. 提升开发效率
  2. 提高模块化
  3. 便于单元测试

为什么我们需要它?

先给出一个例子,传统模式下当我们创建汽车 (Car) 这个类的时候,我们需要依赖轮子,发动机。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }
}

在 Car 这个类的构造器中我们装备了这个类中需用到的依赖项,这有什么问题呢?正如你所见,构造器不仅需要把依赖赋值到当前类内部属性上还需要把依赖实例化。比如 Engine 是通过 new 实例化的, 而 Tires 是通过工厂模式创建的。这样的高度耦合的依赖关系大大增加了单元测试难度和后期维护的成本。必然会出现牵一发而动全身的情形。而且在依赖 hard-code 写死在代码中并不符合 SOLID 开发原则中的 “开闭原则”。试想一个程序中,我们有超多种类的 Car,他们都依赖同一个依赖 Engine,但是有一天我想把所有的 Engine 换成 V8Engine 我该怎么做?全局搜索 Engine 修改为 V8Engine,想想都有点麻烦。


每辆车都需要自己控制引擎的创建

然后我们尝试一下 IoC 的版本。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';
import { Container } from 'path/to/container';

const container = new Container();
container.bind('engine', Engine);
container.bind('tires', Tires);

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = container.get('engine');
    this.tires = container.get('tires');
  }
}

现在引擎和轮胎的创建不再直接依赖它们的构造函数,而是通过 IoC 容器 (container) 来创建,使得 Car 类 和 Engine,Tires 没有了强耦合关系。代码中不再依赖于具体,而是依赖于 container 抽象容器,即要针对接口编程,不针对实现编程。过去思维中想要什么依赖,需要自己去 “拉” 改为抽象容器主动 “推” 给你,你只管使用实体就可以了。这是依赖倒转 (DIP) 的一种表现形式。


所有车装有引擎

因为汽车不直接依赖引擎,所以现在我想把所有引擎换成 V8 引擎,只需要把 IoC 容器中的引擎替换掉就可以了。


所有车装有 V8 引擎

原理

首先让我们实现一个最简单的容器来管理依赖,这里省略了大量类型定义,类型判断和异常处理,并不适用于生产环境。

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, constructor) {
    this.constructorPool.set(name, constructor);
  }

  get(name) {
    const target = this.constructorPool.get(name);
    return new target();
  }
  
}

container.register('myClass', DemoClass);
const classInstance = container.get('myClass');

constructorPool 是存放所有依赖的集合, 这是最简单的对象池,池中存储着构造函数和唯一标识符的集合。当调用 get 方法时,根据唯一标识符从对象池中拿到构造函数并返回实例,这只考虑了在注册时如参是构造函数,并且每次 get 的时候都返回新的实例。当我们需要在全局使用单一实例,并且在不同的地方拿到同一个实例,就需要在注册 (register) 的时候添加配置区分是单例模式还是工厂模式

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, definition, dependencies) {
    this.constructorPool.set(name, {
      definition: definition,
      dependencies: dependencies
    });
  }

  get(name) {
    const targetConstructor = this.constructorPool.get(name);
    if (this._isClass(targetConstructor.definition)) {
      return this._createInstance(targetConstructor);
    } else {
      return targetConstructor.definition;
    }
  }
  
  // 递归拿到类的所有依赖集合
  _getResolvedDependencies(target) {
    let classDependencies = [];
    if (target.dependencies) {
      classDependencies = target.dependencies.map(dependency => {
        return this.get(dependency);
      });
    }
    return classDependencies;
  }

  _createInstance(target) {
    return new target.definition(...this._getResolvedDependencies(service));
  }

  // 判断是否为构造函数
  _isClass(definition) {
    return Object.prototype.toString.call(definition) === "[object Function]";
  }
}

而且依赖容器中需要维护一套自己的生命周期去满足连接数据库等需求,这里建议大家读一下 midway 团队出品的 injection ,这里有更完整的解决方案。

可测性

接下来我们用实际开发的例子看一下 IoC 是如何提高代码的可测性。

这里还是使用汽车的例子。

import { Engine } from 'engine/path';
import { Tires } from 'tires/path';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }

  async run() {
    const engineStatus = await this.engine.check();
    const tiresStatus = await this.tires.check();

    if (engineStatus && tiresStatus) {
      return console.log('car running.');
    }
    return console.log('car broken');
  }
}

当我们实例化 Car 之后,执行 run 的时候,我们会调用 engine 和 tires 依赖里的方法,这个方法有可能会有外部依赖,比如从数据库中读数据,或者一次 http 请求。

export class Engine {
  private health = true;
  async check() {
    const result1 = await http.get('demo'); //check 1
    const result2 = await db.find({         //check 2
      id: 'demoId'
    });                                     
    const result3 = this.health;            //check 3

    return result1 && result2 && result3;
  }
}

当生产环境下我们执行 check,我们期望 3 个 check 都是 true 才让引擎发动,但是在测试阶段,我们只想执行 check3,忽略 check1 和 check2,这在传统开发模式下是很难做的,因为在 Car 构造函数中,已经写死了 Engine 的创建。想在测试阶段提供一个永远保持健康状态的引擎只能通过实例化时判断环境变量,赋值不同的实例,或者修改构造函数。

实例化时判断环境。

class Car {
  private engine;
  public running = false;

  constructor() {
    if (process.env === 'test') {
      this.engine = new TestEngine();
    } else {
      this.engine = new Engine();
    }
  }

  async run() {
    const engineStatus = await this.engine.check();

    return this.running = engineStatus;
}

公用类判断环境。

export class Engine {
  private health = true;
  async check() {
    if (process.env === 'test') {
      // test check
    } else {
      // normal check
    }
  }
}

这两种方式都不是优雅的解决方案,这种脏代码不应该在项目中出现。为了单元测试而需要判断执行环境的代码不应该写在具体实现上,而是应该放在公共的地方统一处理。

借由 IoC 容器,我们的业务代码不需要为单元测试作出修改,只需要在测试的时候,把测试的实例注册到 IoC 的容器中就可以了。

class Car {
  private engine;
  public running = false;

  constructor() {
    this.engine = container.get('engine');
  }

  async run() {
    const engineStatus = await this.engine.check();

    if (engineStatus) {
      return this.running = true;
    }
    return this.running = false;
  }
}

通过 IoC 我们可以优雅的处理测试环境下,业务代码中需要的依赖实体。因为当测试开始时,我们可以通过配置创建符合预期的类放到对象池中,业务代码中只需要直接使用就可以了。

以下给出一段对于 Car 的测试代码。

// car.spec.js
const Car = require('./car');

describe('Car', function () {
  it('#car.run', async function () {
    // 注册测试用依赖
    container.register('engine', MockEngine);

    const car = new Car();

    await car.run()

    expect(car.running).to.eql(true);
  });
});

社区最佳实践

在前端领域,反转控制可能被提及的比较少 (Angular 2 发布之前),但是在服务端领域, IoC 有很多实现,比如 Java 的 Spring 框架,PHP 的 Laravel 等等。Angular 的出现让我对前端工程化有了新的见解,Angular 把依赖注入作为应用设计模式,在框架的高度管理所有依赖和帮助开发者获取依赖,Angular 官方自己维护了一套自己的 DI 框架。

想揭开 DI 的神秘面纱需要了解两个东西。

首先是 @Injectable。这是 JavaScript 装饰器 (Decorators) 语法特性,装饰器语法已经进入 TC39 提案 Stage 2,但是还没正式进入 ECMA 语法标准。这个特发特性是使类可被注入的关键。开发者可以使用注解的方式自定义类的行为,方法,和运行时的属性。在 Angular 中使用 @Injectable 注解向 IoC 容器注册。angular/packages/core/src/di/ 在这个命名空间下 Angular 组织了 DI 的逻辑。框架提供了一套解决方案跟踪被注解的所有依赖,当你需要时提供正确的实例。

然后是 reflect-metadata。这个包提供了读取和修改类的源数据的能力,是帮助 Angular 判断被注入方所需实例类型的关键点。当使用这个包时,必须设置在 tsconfig.json 中开启 emitDecoratorMetadata: true 。

通过这两位的帮助,TypeScript 便可在编译时拿到被注解类的原数据,而且这些原属组是在运行时可用的。

总结

因篇幅原因,这里只是简单介绍 IoC 的使用,控制反转设计模式的优点是显而易见的,它有益于编写单元测试。因为依赖的实例化交给了容器,所以减少了实例化模版代码。让程序更易于扩展。去除代码之间的直接依赖关系,降低了耦合度。控制反转离不开依赖注入,现阶段社区中解决方案是通过 reflect-metadata 和装饰器来进行注入。

依赖注入

https://www.zhihu.com/question/32108444

第一章:小明和他的手机

从前有个人叫小明

小明有三大爱好,抽烟,喝酒…… 咳咳,不好意思,走错片场了。应该是逛知乎、玩王者农药和抢微信红包


小明的三大爱好

我们用一段简单的伪代码,来制造一个这样的小明

class Ming extends Person
{
    private $_name;

    private $_age;

    function read()
    {
        //逛知乎
    }

    function  play()
    {
        //玩农药
    }

    function  grab()
    {
        //抢红包
    }

}

但是,小明作为一个人类,没有办法仅靠自己就能实现以上的功能,他必须依赖一部手机,所以他买了一台iphone6,接下来我们来制造一个iphone6

class iPhone6 extends Iphone
{
    function read($user="某人")
    {
        echo $user."打开了知乎然后编了一个故事 \n";
    }

    function play($user="某人")
    {
        echo $user."打开了王者农药并送起了人头 \n";
    }

    function grab($user="某人")
    {
        echo $user."开始抢红包却只抢不发 \n";
    }
}

小明非常珍惜自己的新手机,每天把它牢牢控制在手心里,所以小明变成了这个样子

class Ming extends Person
{
    private $_name;

    private $_age;

    public function  __construct()
    {
        $this->_name = '小明';
        $this->_age = 26;
    }

    function read()
    {
        //……  省略若干代码
        (new iPhone6())->read($this->_name); //逛知乎
    }

    function  play()
    {
        //……  省略若干代码
        (new iPhone6())->play($this->_name);//玩农药

    }

    function  grab()
    {
        //……  省略若干代码
        (new iPhone6())->grab($this->_name);//抢红包

    }

}

今天是周六,小明不用上班,于是他起床,并依次逛起了知乎,玩王者农药,并抢了个红包。

$ming = new Ming();  //小明起床
$ming->read();
$ming->play();
$ming->grab();

这个时候,我们可以在命令行里看到输出如下

小明打开了知乎然后编了一个故事 
小明打开了王者农药并送起了人头 
小明开始抢红包却只抢不发

这一天,小明过得很充实,他觉得自己是世界上最幸福的人。

第二章: 小明的快乐与忧伤

小明和他的手机曾一起度过了一段美好的时光,一到空闲时刻,他就抱着手机,逛知乎,刷微博,玩游戏,他觉得自己根本不需要女朋友,只要有手机在身边,就满足了。

可谁能想到,一次次地系统更新彻底打碎了他的梦想,他的手机变得越来越卡顿,电池的使用寿命也越来越短,一直到某一天的寒风中,他的手机终于耐不住寒冷,头也不回地关了机。

小明很忧伤,他意识到,自己要换手机了。

为了能获得更好的使用体验,小明一咬牙,剁手了一台iphoneX,这部手机铃声很大,电量很足,还能双卡双待,小明很喜欢,但是他遇到一个问题,就是他之前过度依赖了原来那一部iPhone6,他们之间已经深深耦合在一起了,如果要换手机,他就要拿起刀来改造自己,把自己体内所有方法中的iphone6 都换成 iphoneX。


漫长的改造过程

经历了漫长的改造过程,小明终于把代码中的 iphone6 全部换成了 iphoneX。虽然很辛苦,但是小明觉得他是快乐的。

于是小明开开心心地带着手机去上班了,并在回来的路上被小偷偷走了。为了应急,小明只好重新使用那部刚刚被遗弃的iphone6,但是一想到那漫长的改造过程,小明的心里就说不出的委屈,他觉得自己过于依赖手机了,为什么每次手机出什么问题他都要去改造他自己,这不仅仅是过度耦合,简直是本末倒置,他向天空大喊,我不要再控制我的手机了。

天空中的造物主,也就是作为程序员的我,听到了他的呐喊,我告诉他,你不用再控制你的手机了,交给我来管理,把控制权交给我。这就叫做控制反转

第三章:造物主的智慧

小明听到了我的话,他既高兴,又有一点害怕,他跪下来磕了几个头,虔诚地说到:“原来您就是传说中的造物主,巴格梅克上神。我听到您刚刚说了 控制反转 四个字,就是把手机的控制权从我的手里交给你,但这只是您的想法,是一种思想罢了,要用什么办法才能实现控制反转,又可以让我继续使用手机呢?”

“呵“,身为造物主的我在表现完不屑以后,扔下了四个大字,“依赖注入!”

接下来,伟大的我开始对小明进行惨无人道的改造,如下

class Ming extends Person
{
    private $_name;

    private $_age;

    private $_phone; //将手机作为自己的成员变量

    public function  __construct($phone)
    {
        $this->_name = '小明';
        $this->_age = 26;
        $this->_phone = $phone;
        echo "小明起床了 \n";
    }

    function read()
    {
        //……  省略若干代码
        $this->_phone->read($this->_name); //逛知乎
    }

    function  play()
    {
        //……  省略若干代码
        $this->_phone->play($this->_name);//玩农药

    }

    function  grab()
    {
        //……  省略若干代码
        $this->_phone->grab($this->_name);//抢红包

    }

}

接下来,我们来模拟运行小明的一天

$phone = new IphoneX(); //创建一个iphoneX的实例
if($phone->isBroken()){//如果iphone不可用,则使用旧版手机
    $phone = new Iphone6();
}
$ming = new Ming($phone);//小明不用关心是什么手机,他只要玩就行了。
$ming->read();
$ming->play();
$ming->grab();

我们先看一下iphoneX 是否可以使用,如果不可以使用,则直接换成iphone6,然后唤醒小明,并把手机塞到他的手里,换句话说,把他所依赖的手机直接注入到他的身上,他不需要关心自己拿的是什么手机,他只要直接使用就可以了。

这就是依赖注入

第四章:小明的感悟

小明的生活开始变得简单了起来,而他把省出来的时间都用来写笔记了,他在笔记本上这样写到

我曾经有很强的控制欲,过度依赖于我的手机,导致我和手机之间耦合程度太高,只要手机出现一点点问题,我都要改造我自己,这实在是既浪费时间又容易出问题。自从我把控制权交给了造物主,他每天在唤醒我以前,就已经替我选好了手机,我只要按照平时一样玩手机就可以了,根本不用关心是什么手机。即便手机出了问题,也可以由造物主直接搞定,不需要再改造我自己了,我现在买了七部手机,都交给了造物主,每天换一部,美滋滋!
我也从其中获得了这样的感悟: 如果一个类A 的功能实现需要借助于类B,那么就称类B是类A的依赖,如果在类A的内部去实例化类B,那么两者之间会出现较高的耦合,一旦类B出现了问题,类A也需要进行改造,如果这样的情况较多,每个类之间都有很多依赖,那么就会出现牵一发而动全身的情况,程序会极难维护,并且很容易出现问题。要解决这个问题,就要把A类对B类的控制权抽离出来,交给一个第三方去做,把控制权反转给第三方,就称作控制反转(IOC Inversion Of Control)控制反转是一种思想,是能够解决问题的一种可能的结果,而依赖注入(Dependency Injection)就是其最典型的实现方法。由第三方(我们称作IOC容器)来控制依赖,把他通过构造函数、属性或者工厂模式等方法,注入到类A内,这样就极大程度的对类A和类B进行了解耦

go 静态检查工具

看了看日历,现在已经是 2021 年了,偶尔还是能看到有人在发诸如 《http body 未关闭导致线上事故》,或者 《sql.Rows 未关闭半夜惊魂》类的文章,令人有一种梦回 2015 的感觉。

在这个 Go 的静态分析工具已经强到烂大街的时代,写这些文章除了暴露这些人所在的公司基础设施比较差,代码质量低以外,并不能体现出什么其它的意思了。毕竟哪怕是不懂怎么读源码,这样的问题你 Google 搜一下也知道是怎么回事了。

特别是有些人还挂着大公司的 title,让人更加不能理解了。下面是简单的静态分析工具的科普,希望给那些还在水深火热的 Gopher 们送点解药。

何谓静态分析

静态分析是通过扫描并解析用户代码,寻找代码中的潜在 bug 的一种手段。

静态分析一般会集成在项目上线的 CI 流程中,如果分析过程找到了 bug,会直接阻断上线,避免有问题的代码被部署到线上系统。从而在部署早期发现并修正潜在的问题。

图片

社区常见 linter

时至今日,社区已经有了丰富的 linter 资源供我们使用,本文会挑出一些常见 linter 进行说明。

go lint

go lint 是官方出的 linter,是 Go 语言最早期的 linter 了,其可以检查:

  • 导出函数是否有注释
  • 变量、函数、包命名不符合 Go 规范,有下划线
  • receiver 命名是否不符合规范

但这几年社区的 linter 蓬勃发展,所以这个项目也被官方 deprecated 掉了。其主要功能被另外一个 linter:revive[^1] 完全继承了。

go vet

go vet 也是官方提供的静态分析工具,其内置了锁拷贝检查、循环变量捕获问题、printf 参数不匹配等工具。

比如新手老手都很容易犯的 loop capture 错误:

package main

func main() {
 var a = map[int]int {1 : 1, 2: 3}
 var b = map[int]*int{}
 for k, r := range a {
  go func() {
   b[k] = &r
  }()
 }
}

go vet 会直接把你骂醒:

~/test git:master ❯❯❯ go vet ./clo.go
# command-line-arguments
./clo.go:8:6: loop variable k captured by func literal
./clo.go:8:12: loop variable r captured by func literal

执行 go tool vet help 可以看到 go vet 已经内置的一些 linter。

~ ❯❯❯ go tool vet help
vet is a tool for static analysis of Go programs.

vet examines Go source code and reports suspicious constructs,
such as Printf calls whose arguments do not align with the format
string. It uses heuristics that do not guarantee all reports are
genuine problems, but it can find errors not caught by the compilers.

Registered analyzers:

    asmdecl      report mismatches between assembly files and Go declarations
    assign       check for useless assignments
    atomic       check for common mistakes using the sync/atomic package
    bools        check for common mistakes involving boolean operators
    buildtag     check that +build tags are well-formed and correctly located
    cgocall      detect some violations of the cgo pointer passing rules
    composites   check for unkeyed composite literals
    copylocks    check for locks erroneously passed by value
    errorsas     report passing non-pointer or non-error values to errors.As
    httpresponse check for mistakes using HTTP responses
    loopclosure  check references to loop variables from within nested functions
    lostcancel   check cancel func returned by context.WithCancel is called
    nilfunc      check for useless comparisons between functions and nil
    printf       check consistency of Printf format strings and arguments
    shift        check for shifts that equal or exceed the width of the integer
    stdmethods   check signature of methods of well-known interfaces
    structtag    check that struct field tags conform to reflect.StructTag.Get
    tests        check for common mistaken usages of tests and examples
    unmarshal    report passing non-pointer or non-interface values to unmarshal
    unreachable  check for unreachable code
    unsafeptr    check for invalid conversions of uintptr to unsafe.Pointer
    unusedresult check for unused results of calls to some functions

默认情况下这些 linter 都是会跑的,当前很多 IDE 在代码修改时会自动执行 go vet,所以我们在写代码的时候一般就能发现这些错了。

但 go vet 还是应该集成到线上流程中,因为有些程序员的下限实在太低。

errcheck

Go 语言中的大多数函数返回字段中都是有 error 的:

func sayhello(wr http.ResponseWriter, r *http.Request) {
 io.WriteString(wr, "hello")
}

func main() {
 http.HandleFunc("/", sayhello)
 http.ListenAndServe(":1314", nil) // 这里返回的 err 没有处理
}

这个例子中,我们没有处理 http.ListenAndServe 函数返回的 error 信息,这会导致我们的程序在启动时发生静默失败。

程序员往往会基于过往经验,对当前的场景产生过度自信,从而忽略掉一些常见函数的返回错误,这样的编程习惯经常为我们带来意外的线上事故。例如,规矩的写法是下面这样的:

data, err := getDataFromRPC()
if err != nil {
 return nil, err
}

// do business logic
age := data.age

而自信的程序员可能会写成这样:

data, _ := getDataFromRPC()

// do business logic
age := data.age

如果底层 RPC 逻辑出错,上层的 data 是个空指针也是很正常的,如果底层函数返回的 err 非空时,我们不应该对其它字段做任何的假设。这里 data 完全有可能是个空指针,造成用户程序 panic。

errcheck 会强制我们在代码中检查并处理 err。

gocyclo

gocyclo 主要用来检查函数的圈复杂度。圈复杂度可以参考下面的定义:

圈复杂度(Cyclomatic complexity)是一种代码复杂度的衡量标准,在 1976 年由 Thomas J. McCabe, Sr. 提出。在软件测试的概念里,圈复杂度用来衡量一个模块判定结构的复杂程度,数量上表现为线性无关的路径条数,即合理的预防错误所需测试的最少路径条数。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系。

看定义较为复杂但计算还是比较简单的,我们可以认为:

  • 一个 if,圈复杂度 + 1
  • 一个 switch 的 case,圈复杂度 + 1
  • 一个 for 循环,圈复杂度 + 1
  • 一个 && 或 ||,圈复杂度 + 1

在大多数语言中,若函数的圈复杂度超过了 10,那么我们就认为该函数较为复杂,需要做拆解或重构。部分场景可以使用表驱动的方式进行重构。

由于在 Go 语言中,我们使用 if err != nil 来处理错误,所以在一个函数中出现多个 if err != nil 是比较正常的,因此 Go 中函数复杂度的阈值可以稍微调高一些,15 是较为合适的值。

下面是在个人项目 elasticsql 中执行 gocyclo 的结果,输出 top 10 复杂的函数:

~/g/s/g/c/elasticsql git:master ❯❯❯ gocyclo -top 10  ./
23 elasticsql handleSelectWhere select_handler.go:289:1
16 elasticsql handleSelectWhereComparisonExpr select_handler.go:220:1
16 elasticsql handleSelect select_handler.go:11:1
9 elasticsql handleGroupByFuncExprDateHisto select_agg_handler.go:82:1
9 elasticsql handleGroupByFuncExprDateRange select_agg_handler.go:154:1
8 elasticsql buildComparisonExprRightStr select_handler.go:188:1
7 elasticsql TestSupported select_test.go:80:1
7 elasticsql Convert main.go:28:1
7 elasticsql handleGroupByFuncExpr select_agg_handler.go:215:1
6 elasticsql handleSelectWhereOrExpr select_handler.go:157:1

bodyclose

使用 bodyclose[^2] 可以帮我们检查在使用 HTTP 标准库时忘记关闭 http body 导致连接一直被占用的问题。

resp, err := http.Get("http://example.com/") // Wrong case
if err != nil {
 // handle error
}
body, err := ioutil.ReadAll(resp.Body)

像上面这样的例子是不对的,使用标准库很容易犯这样的错。bodyclose 可以直接检查出这个问题:

# command-line-arguments
./httpclient.go:10:23: response body must be closed

所以必须要把 Body 关闭:

resp, err := http.Get("http://example.com/")
if err != nil {
 // handle error
}
defer resp.Body.Close() // OK
body, err := ioutil.ReadAll(resp.Body)

HTTP 标准库的 API 设计的不太好,这个问题更好的避免方法是公司内部将 HTTP client 封装为 SDK,防止用户写出这样不 Close HTTP body 的代码。

sqlrows

与 HTTP 库设计类似,我们在面向数据库编程时,也会碰到 sql.Rows 忘记关闭的问题,导致连接大量被占用。sqlrows[^3] 这个 linter 能帮我们避免这个问题,先来看看错误的写法:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
    return nil, err
}

for rows.Next() {
 err = rows.Scan(...)
 if err != nil {
  return nil, err // NG: this return will not release a connection.
 }
}

正确的写法需要在使用完后关闭 sql.Rows:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
defer rows.Close() // NG: using rows before checking for errors
if err != nil {
    return nil, err
}

与 HTTP 同理,公司内也应该将 DB 查询封装为合理的 SDK,不要让业务使用标准库中的 API,避免上述错误发生。

funlen

funlen[^4] 和 gocyclo 类似,但是这两个 linter 对代码复杂度的视角不太相同,gocyclo 更多关注函数中的逻辑分支,而 funlen 则重点关注函数的长度。默认函数超过 60 行和 40 条语句时,该 linter 即会报警。

linter 集成工具

一个一个去社区里找 linter 来拼搭效率太低,当前社区里已经有了较好的集成工具,早期是 gometalinter,后来性能更好,功能更全的 golangci-lint 逐渐取而代之。目前 golangci-lint 是 Go 社区的绝对主流 linter。

golangci-lint

golangci-lint[^5] 能够通过配置来 enable 很多 linter,基本主流的都包含在内了。

在本节开头讲到的所有 linter 都可以在 golangci-lint 中进行配置,

使用也较为简单,只要在项目目录执行 golangci-lint run . 即可。

~/g/s/g/c/elasticsql git:master ❯❯❯ golangci-lint run .
main.go:36:9: S1034: assigning the result of this type assertion to a variable (switch stmt := stmt.(type)) could eliminate type assertions in switch cases (gosimple)
 switch stmt.(type) {
        ^
main.go:38:34: S1034(related information): could eliminate this type assertion (gosimple)
  dsl, table, err = handleSelect(stmt.(*sqlparser.Select))
                                 ^
main.go:40:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleUpdate(stmt.(*sqlparser.Update))
                      ^
main.go:42:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleInsert(stmt.(*sqlparser.Insert))
                      ^
select_handler.go:192:9: S1034: assigning the result of this type assertion to a variable (switch expr := expr.(type)) could eliminate type assertions in switch cases (gosimple)
 switch expr.(type) {

参考资料

[1] https://revive.run/

[2] https://github.com/timakin/bodyclose

[3] https://github.com/gostaticanalysis/sqlrows

[4] https://github.com/ultraware/funlen

[5] https://github.com/golangci/golangci-lint

滴滴曹乐:如何成为技术大牛?

https://mp.weixin.qq.com/s/blazNWi-z2mAT9U-c6VdPw

桔妹导读:曹乐,清华大学毕业,16年初加入滴滴,带领团队建设了滴滴网约车技术体系,现任滴滴网约车技术部负责人。面对技术团队同学的成长困惑,曹乐给同学们写过一封信,他从各个维度去阐明自己的见解与想法,帮助同学们不再局限于从技术视角去看待问题,而是拥有更广阔的视野与方法。他围绕如何成为技术大牛这一话题提出以下一些想法:寻找范式、刻意练习、及时反馈;垂直打透、横向迁移、深度复盘;聪明人要下笨功夫。在此再次分享给大家这封信的内容。

很多同学都有关于工程师该如何成长的问题,大家普遍对如何成长为牛人,如何获得晋升,如何在繁忙的工作中持续学习充满了困惑,这其实是每一位同学成长过程中必经之路,在这里也想跟大家分享一下我的一些心得。
同学们普遍对成长充满了焦虑感。工作太忙没时间学习,需求太多太琐碎感觉自己没什么进步,做技术是不是做到35岁以后就没人要了,等等,都是对成长焦虑的体现。这种焦虑是正常的,所有的渴望,在内心的投射其实都是焦虑。任何一个渴望成长的人,不管处于什么阶段,一线工程师,架构师,还是总监,副总裁,其实内心中都是充满了焦虑的,无一例外。对于这种焦虑,我们所要做的是接纳,而不需要过度担忧。这种焦虑并不是说,想明白如何成长了就会没有了,到了某个阶段就会没有了的。成长的脚步和期待一刻不止,内心的焦虑也一刻不会停歇。正是这种焦虑感,驱使你写代码追查问题到星夜,驱使你牺牲休息娱乐的时间和一本本厚厚枯燥的书作伴,驱使你不断努力向前,不舍昼夜。相反的,如果内心中没有这种焦虑,反而是值得担忧的。这可能说明已经习惯呆在自己的舒适区了。在现在这样一个高速发展的社会,以及我们这样一个高速发展和变化的行业,失去对成长的渴望和焦虑反而是一个非常危险的信号。
所谓的程序员35岁危机,其实背后的根本原因是,有太多太多人在工作几年以后,就觉得自己什么都会了,之后的十几年工作只不过是头2-3年的简单重复而已。在我们这样一个行业里,在招聘的时候,如果摆在管理面前的两个人,一个是初出茅庐或刚工作2-3年,充满了对成长的渴望;另一个工作十多年了但水平和工作2-3年的人差不多,只是更熟练一些,不过在舒适区已经躺了十年了。如果负责招聘的是你,你会做出什么样的选择?
而另一方面,其实是高端人才在行业内的极度极度稀缺,这在行业内是非常普遍的现象,真正的大牛太稀缺了。在这样一个行业里,如果一个人能够持续成长,能力和工作年限成正比的持续提升,这样的人,任何时候在行业里都是被疯抢,怎么可能会遇到任何年龄的危机呢?
如何学习,其实是有方法论的,那就是刻意练习。所谓的10000小时成为大牛的理论是片面的,如果只是简单重复10000小时,是不可能成为大牛的。刻意练习包含了三个步骤。第一,找到你要学习的这个领域体系的范式(pattern);第二,针对每个范式刻意的反复学习和练习;第三,及时反馈。
大家在过往的工作和学习生活中,或多或少都在实践着刻意练习。拿面临高考的中学生举例子,好的学生通常是把一门功课拆成了很多知识点(寻找pattern),然后针对知识点以及他们的排列组合,有针对性的反复做各种难度的题(刻意练习),每次做完题都对一下答案看看正确与否,如果错了就思考,记录,复盘(持续及时反馈)。这样的学习方法就是事半功倍的。而事倍功半的学习方法,就是不分青红皂白拿起一本习题或卷子就拼命做,我上学的时候身边不少同学非常勤奋但成绩并不好,多半都是这个原因。再举一个我最近在学打羽毛球的例子,正确的学习方法是把打羽毛球拆解成步法和手上动作,小碎步,米字步,正反手挑球,放网,正手和头顶高远球吊球杀球等(寻找pattern),然后针对每一个动作反复练习(刻意练习),然后请教练或者录下来看视频纠正自己的动作(及时反馈);而错误的学习方法是,上来就盲目找人打比赛,以赛代练,这样的进步是很慢的,而且错误的动作形成习惯以后未来反而很难纠正。
当学习方法不正确的时候,刻苦的学习常常只是看起来很勤奋,并没有应有的效果。当接触一个陌生领域的时候,错误的学习方法是不带目的性,上来就找一堆相关的大部头开始啃。而正确的学习方法应该是快速梳理该领域的知识点,形成框架体系(寻找pattern),这里有些小窍门可以快速构建起一个领域的知识点体系,例如看一些该领域的综述性或开创性的文章(看论文,别瞎看网上的文章),或者找本该领域综述性的教科书看它的目录(注意,好的教科书的目录往往就是这个领域的知识框架,内容倒不一定非要看下去)。然后,针对每个知识点,找书里的相关章节,该领域相关paper里的相关section深入学习,建立起自己对这个知识点的理解(刻意练习)。最后,再把知识点和现实工作中的情况(自己工作,或其他公司相关的工作)进行对照(及时反馈),从而建立对一个知识点的深度理解,最后融会贯通建立对一个领域的理解。
这样说可能有点抽象,拿我当年学习分布式存储的过程为例子,先结合自己的工作内容梳理出需要深入了解的知识点(例如,元信息组织,Meta Server设计和HA,副本组织和管理,Recovery,Rebalance,单机存储引擎,数据/元信息流,纠删码,一致性,多租户,存储介质,网络环境和IDC等等),同时看很多综述性的材料,梳理分布式存储的知识点(有网上各种整理的比较好的文章,也有从各种系统实现的paper里抽出),不断迭代构建分布式存储领域的知识点(寻找pattern,这是最难的一个过程);然后针对每一个知识点,找相关材料进行深度学习,例如,对于分布式一致性,需要阅读CAP理论,Paxos的论文,Raft的论文等等以及周边的很多材料(刻意练习);然后找各种系统实现的论文或文章,比如GFS,Dynamo,Aurora,OceanBase,Ceph,Spanner等等,看看和对比它们在一致性上是如何考虑和取舍的,当然,最重要的是结合自己工作中的反复实践和所学知识点进行比对(及时反馈)。
这三个阶段并不是割裂的,而是周而复始的,经常会在刻意练习和及时反馈的学习过程中,发现自己遗漏的知识点,或者发现自己梳理的两个知识点其实是重合的。通过这种交叉比对,以及在实践中不断检验的方式建立的知识点是非常可落地的,而不会看了几篇论文以后就人云亦云。拿分布式存储的一致性举例子,如果不是反复对比、思考和反复实践,你不会发现GFS论文里最难的一段,多个Writer对一个文件进行append的逻辑,在实践中根本没用;你也不会发现看起来优雅而学术的CAP三选二的理论,实践中压根不是这么完美,很多时候只能三选一;你也不会发现Dynamo论文里的Vector Clock,网上有无数文章摇头晃脑的解读,但在Amazon的应用场景里是个典型的over design,Cassandra在这点就务实很多。
这时候大家可能会有个疑问,工作本身就如此繁忙了,哪里能抽出足够多的时间去学习?
其实工作和学习本身,是不应该被割裂的。工作本来就应该是学习的一部分,是学习中的实践和及时反馈的部分。学习如果脱离工作的实践,其实是非常低效的。因此每个同学应该对自己工作所在的这个技术和业务领域进行系统性的学习,并在工作中反复实践和验证。不同的领域之间其实是融汇贯通的,当你对一个领域精通并总结出方法论以后,很容易就能上手别的领域。因此花几年实践彻底研究透一个领域,对于刚工作几年的同学来说,是非常重要,甚至是必须的,也只有在一个领域打透之后才谈得上跨领域迁移,去拓展自己的知识面。更直接的说,对于一个领域还未完全掌握的同学,深度是最重要的,不用想广度的事情,等掌握了一个领域之后,再去拓展广度就变得很容易了。
这里一个常见的误区是,学习的内容和工作的领域没有太多直接的关系。例如,我以前曾经花了非常大的功夫去读Linux内核的源代码以及很多相关的大部头,几乎花掉了我将近两年的所有空闲时间,然而在我这些年的工作里,几乎是没有用处的,最多就是有一些“启发”,ROI实在是太低了,现在也忘得差不多了。更重要的,软件工程是一门实践科学,从书本上得到的知识如果没有在实践中应用和检验,基本上是没有用处的。举一个例子,很多优秀的架构师,尽管日常工作中可能反复在用,但未必说得出开闭原则,里氏替换原则,迪米特法则等等,反过来,对面向对象设计这7大原则出口成章的人,很多其实离真正的架构师还远得很,有些甚至只是博客架构师而已。实践远远比看书,看文章重要得多,上文所述的我构建自己分布式存储知识体系的过程,看起来好像都是看材料,看论文,而实际上80%的收获都来源于带着理论的实践,和从实践中总结沉淀的理论。因此,彻底搞明白自己工作所在的技术和业务领域,是最务实高效的做法,工作和学习割裂,会导致工作和学习都没做好。
这时候大家可能会有另一个疑问,感觉日常工作非常琐碎,学不到什么东西,怎么办?
如果把学习分成从书本中学,和从工作中学这两种的话,那毫无疑问,工作中的“知识密度”,比起书本的“知识密度”,肯定是要低很多的,因为书本里的知识,那都是人家从他们的工作中抽象总结出来的。这也是为什么大家普遍觉得日常工作“琐碎”。然而工作中每个点滴的琐事与平凡,都是可以抽象总结成为方法论的,更别说工作所在的领域自身的博大精深了。从日常工作中学习的秘诀,就是“行动中思考”。
对于每一个软件工程师,最重要的两个能力,是写代码的能力和trouble shooting的能力。并且,要成为优秀的架构师,出色的开发能力和追查问题的能力是一切的基础。提高写代码的能力的核心,首先在于坚持不断的写,但更重要的,在于每天,每周,持续不断的review自己之前的代码;同时,多review牛人写的代码,比如是团队里你觉得代码写的比你好的同事,比如社区里以代码漂亮著称的开源代码(作为一个C++程序员,当年我的榜样之一是boost库)。一旦觉得自己之前的代码不够好,就立刻复盘,立刻重构。更重要的是,多思考自己代码和好的代码之间不同之处背后的为什么,通常这就是为什么这些代码更好的背后的秘密。
特别要说明的是,代码规范除了知道是什么外,要格外重视思考每一个代码规范背后的为什么。代码规范的每一句话,背后无一例外都是一片江湖上的血泪史。要提高trouble shooting的能力,关键在于要深度复盘自己遇到的每一个问题,包括线上的,包括测试发现的,寻找每一个问题,每一次事故背后的root cause,并且思考后续如何避免同类问题,如何更快的发现同类问题。要对团队内外遇到的所有问题都要保持好奇心,关注一下周边的事故、问题背后的root cause。Trouble shooting能力的提高是几乎无法从书本上得到的,完全来源于对每一个问题的深度思考,以及广泛积累每一个问题。对于架构师而言,可能未必在一线写代码了,但看团队中一个架构师是否真正牛逼的一个很重要标准,就是看他是否能够追查出团队其他同学查不出来的问题。我见过的一个真正牛的架构师,对于系统中疑难杂症,通常问几个问题,就能大致猜出是哪里出的问题,以及可能的原因是什么,准确程度如同算命,屡试不爽,令人叹为观止。
对于一个架构师,除了更加优秀的代码能力和trouble shooting能力外,需要构建相对完整的当前技术领域的知识体系,需要有体系化的思维能力,需要对技术所服务的业务有非常深入的了解。体系化的思维能力,来源于两个方面。一方面是在日常工作中,对每一个接口设计,每一个逻辑,每一个模块、子系统的拆分和组织方式,每一个需求的技术方案,每一个系统的顶层设计,都要反复思考和推敲,不断地复盘。另一方面,需要大量广泛地学习行业内相似系统的架构设计,这其实就是开天眼,只是技术相对来说,行业内的交流更加频繁。淘宝、美团、百度、Google、Facebook、Amazon等各个公司介绍系统架构的论文和PPT铺天盖地,需要带着问题持续学习。除了技术领域本身外,架构师需要非常了解业务上是如何使用我们的系统的,否则非常容易over design,陷入技术的自嗨中,这也是为什么我说Amazon Dynamo论文里讲的Vector Clock是个over design的原因。
另一方面,很多时候技术上绕不过去的坎,可能非常复杂的实现,往往只需要上层业务稍微变通一下,就完全可以绕过去,这也是为什么我说GFS论文里,多个Writer同时Append同一个文件是个根本没用的设计(实际上Google内部也把这个功能去掉了)。只有真正知道上层业务是如何使用系统的,才可能真正做好架构。深入了解业务并不难,对于每个同学,只要对于每一个接到的需求,对于每一个需求评审中的需求,对于周边同学或团队要做的需求,都深入思考为什么业务要提出这个需求,这个需求解决了业务的什么问题,有没有更好的方案。遇到不明白的多和周边同学、产品、运营同学请教。最怕的是自己把自己限定为纯粹的研发,接到需求就无脑做,这等于放弃了主动思考。衡量一个人是不是好的架构师,也有一个方法。对于一个需求,如果他给出了好几个可行的方案,说这些方案也可以,那些方案也可以,往往说明他在架构师的路上还没有完全入门。架构师的难点不在于给出方案,而在于找到唯一的那一个最简单优雅的方案。
总结起来看,行动中思考,就是始终保持好奇,不断从工作中发现问题,不断带着问题回到工作中去;不断思考,不断在工作中验证思考;不断从工作中总结抽象,不断对工作进行复盘,持续不断把工作内容和全领域的知识交叉验证,反复实践的过程。
在工作所在的技术和业务领域中刻意练习,加上行动中思考,就是成为技术大牛的秘诀。
看起来方法也不复杂,为什么大牛还是非常稀少?
尽管我们通篇都在讲方法,但其实在成为技术大牛的路上,方法反而是没那么重要的。真正困难的,在于数年,数十年如一日的坚持。太多人遇到挫折,遇到瓶颈,就觉得手头的事情太乏味枯燥,就想要换一个方向,换一个领域,去学新的技术,新的东西。而真正能够成为大牛的,必须是能够青灯古佛,熬得住突破瓶颈前长时间的寂寞的,必须是肯下笨功夫的聪明人。因此,和坚持相比,方法其实并没有那么重要。

Linux 命令神器:lsof 入门

lsof是系统管理/安全的尤伯工具。我大多数时候用它来从系统获得与网络连接相关的信息,但那只是这个强大而又鲜为人知的应用的第一步。将这个工具称之为lsof真实名副其实,因为它是指“列出打开文件(lists openfiles)”。而有一点要切记,在Unix中一切(包括网络套接口)都是文件。

有趣的是,lsof也是有着最多开关的Linux/Unix命令之一。它有那么多的开关,它有许多选项支持使用-和+前缀。

  1. usage: [-?abhlnNoOPRstUvV] [+|-c c] [+|-d s] [+D D] [+|-f[cgG]]
  2. [-F [f]] [-g [s]] [-i [i]] [+|-L [l]] [+|-M] [-o [o]]
  3. [-p s] [+|-r [t]] [-S [t]] [-T [t]] [-u s] [+|-w] [-x [fl]] [–] [names]

正如你所见,lsof有着实在是令人惊讶的选项数量。你可以使用它来获得你系统上设备的信息,你能通过它了解到指定的用户在指定的地点正在碰什么东西,或者甚至是一个进程正在使用什么文件或网络连接。

对于我,lsof替代了netstat和ps的全部工作。它可以带来那些工具所能带来的一切,而且要比那些工具多得多。那么,让我们来看看它的一些基本能力吧:

关键选项

理解一些关于lsof如何工作的关键性东西是很重要的。最重要的是,当你给它传递选项时,默认行为是对结果进行“或”运算。因此,如果你正是用-i来拉出一个端口列表,同时又用-p来拉出一个进程列表,那么默认情况下你会获得两者的结果。

下面的一些其它东西需要牢记:

  • 默认 : 没有选项,lsof列出活跃进程的所有打开文件
  • 组合 : 可以将选项组合到一起,如-abc,但要当心哪些选项需要参数
  • -a : 结果进行“与”运算(而不是“或”)
  • -l : 在输出显示用户ID而不是用户名
  • -h : 获得帮助
  • -t : 仅获取进程ID
  • -U : 获取UNIX套接口地址
  • -F : 格式化输出结果,用于其它命令。可以通过多种方式格式化,如-F pcfn(用于进程id、命令名、文件描述符、文件名,并以空终止)

获取网络信息

正如我所说的,我主要将lsof用于获取关于系统怎么和网络交互的信息。这里提供了关于此信息的一些主题:

使用-i显示所有连接

有些人喜欢用netstat来获取网络连接,但是我更喜欢使用lsof来进行此项工作。结果以对我来说很直观的方式呈现,我仅仅只需改变我的语法,就可以通过同样的命令来获取更多信息。

  1. # lsof i
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. dhcpcd 6061 root 4u IPv4 4510 UDP *:bootpc
  5. sshd 7703 root 3u IPv6 6499 TCP *:ssh (LISTEN)
  6. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->192.168.1.5:49901 (ESTABLISHED)

使用-i 6仅获取IPv6流量

  1. # lsof i 6

仅显示TCP连接(同理可获得UDP连接)

你也可以通过在-i后提供对应的协议来仅仅显示TCP或者UDP连接信息。

  1. # lsof iTCP
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. sshd 7703 root 3u IPv6 6499 TCP *:ssh (LISTEN)
  5. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->192.168.1.5:49901 (ESTABLISHED)

使用-i:port来显示与指定端口相关的网络信息

或者,你也可以通过端口搜索,这对于要找出什么阻止了另外一个应用绑定到指定端口实在是太棒了。

  1. # lsof i :22
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. sshd 7703 root 3u IPv6 6499 TCP *:ssh (LISTEN)
  5. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->192.168.1.5:49901 (ESTABLISHED)

使用@host来显示指定到指定主机的连接

这对于你在检查是否开放连接到网络中或互联网上某个指定主机的连接时十分有用。

  1. # lsof i@172.16.12.5
  2.  
  3. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->172.16.12.5:49901 (ESTABLISHED)

使用@host:port显示基于主机与端口的连接

你也可以组合主机与端口的显示信息。

  1. # lsof i@172.16.12.5:22
  2.  
  3. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->172.16.12.5:49901 (ESTABLISHED)

找出监听端口

找出正等候连接的端口。

  1. # lsof i sTCP:LISTEN

你也可以grep “LISTEN”来完成该任务。

  1. # lsof i | grep i LISTEN
  2.  
  3. iTunes 400 daniel 16u IPv4 0x4575228 0t0 TCP *:daap (LISTEN)

找出已建立的连接

你也可以显示任何已经连接的连接。

  1. # lsof i sTCP:ESTABLISHED

你也可以通过grep搜索“ESTABLISHED”来完成该任务。

  1. # lsof i | grep i ESTABLISHED
  2.  
  3. firefoxb 169 daniel 49u IPv4 0t0 TCP 1.2.3.3:1863->1.2.3.4:http (ESTABLISHED)

用户信息

你也可以获取各种用户的信息,以及它们在系统上正干着的事情,包括它们的网络活动、对文件的操作等。

使用-u显示指定用户打开了什么

  1. # lsof u daniel
  2.  
  3. snipped
  4. Dock 155 daniel txt REG 14,2 2798436 823208 /usr/lib/libicucore.A.dylib
  5. Dock 155 daniel txt REG 14,2 1580212 823126 /usr/lib/libobjc.A.dylib
  6. Dock 155 daniel txt REG 14,2 2934184 823498 /usr/lib/libstdc++.6.0.4.dylib
  7. Dock 155 daniel txt REG 14,2 132008 823505 /usr/lib/libgcc_s.1.dylib
  8. Dock 155 daniel txt REG 14,2 212160 823214 /usr/lib/libauto.dylib
  9. snipped

使用-u user来显示除指定用户以外的其它所有用户所做的事情

  1. # lsof u ^daniel
  2.  
  3. snipped
  4. Dock 155 jim txt REG 14,2 2798436 823208 /usr/lib/libicucore.A.dylib
  5. Dock 155 jim txt REG 14,2 1580212 823126 /usr/lib/libobjc.A.dylib
  6. Dock 155 jim txt REG 14,2 2934184 823498 /usr/lib/libstdc++.6.0.4.dylib
  7. Dock 155 jim txt REG 14,2 132008 823505 /usr/lib/libgcc_s.1.dylib
  8. Dock 155 jim txt REG 14,2 212160 823214 /usr/lib/libauto.dylib
  9. snipped

杀死指定用户所做的一切事情

可以消灭指定用户运行的所有东西,这真不错。

  1. # kill 9 `lsof -t -u daniel`

命令和进程

可以查看指定程序或进程由什么启动,这通常会很有用,而你可以使用lsof通过名称或进程ID过滤来完成这个任务。下面列出了一些选项:

使用-c查看指定的命令正在使用的文件和网络连接

  1. # lsof c syslogng
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. syslogng 7547 root cwd DIR 3,3 4096 2 /
  5. syslogng 7547 root rtd DIR 3,3 4096 2 /
  6. syslogng 7547 root txt REG 3,3 113524 1064970 /usr/sbin/syslogng
  7. snipped

使用-p查看指定进程ID已打开的内容

  1. # lsof p 10075
  2.  
  3. snipped
  4. sshd 10068 root mem REG 3,3 34808 850407 /lib/libnss_files2.4.so
  5. sshd 10068 root mem REG 3,3 34924 850409 /lib/libnss_nis2.4.so
  6. sshd 10068 root mem REG 3,3 26596 850405 /lib/libnss_compat2.4.so
  7. sshd 10068 root mem REG 3,3 200152 509940 /usr/lib/libssl.so.0.9.7
  8. sshd 10068 root mem REG 3,3 46216 510014 /usr/lib/liblber2.3
  9. sshd 10068 root mem REG 3,3 59868 850413 /lib/libresolv2.4.so
  10. sshd 10068 root mem REG 3,3 1197180 850396 /lib/libc2.4.so
  11. sshd 10068 root mem REG 3,3 22168 850398 /lib/libcrypt2.4.so
  12. sshd 10068 root mem REG 3,3 72784 850404 /lib/libnsl2.4.so
  13. sshd 10068 root mem REG 3,3 70632 850417 /lib/libz.so.1.2.3
  14. sshd 10068 root mem REG 3,3 9992 850416 /lib/libutil2.4.so
  15. snipped

-t选项只返回PID

  1. # lsof t c Mail
  2.  
  3. 350

文件和目录

通过查看指定文件或目录,你可以看到系统上所有正与其交互的资源——包括用户、进程等。

显示与指定目录交互的所有一切

  1. # lsof /var/log/messages/
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. syslogng 7547 root 4w REG 3,3 217309 834024 /var/log/messages

显示与指定文件交互的所有一切

  1. # lsof /home/daniel/firewall_whitelist.txt

高级用法

tcpdump类似,当你开始组合查询时,它就显示了它强大的功能。

显示daniel连接到1.1.1.1所做的一切

  1. # lsof u daniel i @1.1.1.1
  2.  
  3. bkdr 1893 daniel 3u IPv6 3456 TCP 10.10.1.10:1234->1.1.1.1:31337 (ESTABLISHED)

同时使用-t和-c选项以给进程发送 HUP 信号

  1. # kill HUP `lsof -t -c sshd`

lsof +L1显示所有打开的链接数小于1的文件

这通常(当不总是)表示某个攻击者正尝试通过删除文件入口来隐藏文件内容。

  1. # lsof +L1
  2.  
  3. (hopefully nothing)

显示某个端口范围的打开的连接

  1. # lsof i @fw.google.com:2150=2180

结尾

本入门教程只是管窥了lsof功能的一斑,要查看完整参考,运行man lsof命令或查看在线版本。希望本文对你有所助益,也随时欢迎你的评论和指正

资源

一些贼好用的开发原则

https://mp.weixin.qq.com/s/stjzHiF9_oQKOAo9tYGd-Q

 

下图就是我之前整理的一个与设计原则相关的思维导图。

图片

但是不管你整理的多好,很多人到实际写代码的时候完全想不起这些原则。不用自我怀疑,大多数人都是如此,你并不是特例。
之所以会有这样的情况,是因为总结后的原则大多都太抽象了,往往只有一句话,甚至只是一个词,自然不会有太多深刻的印象。
我们今天不聊这些刻板的名词,来聊聊Z哥在工作中常用到的一些“原则”,以及它们的适用场景,帮助你更好地记住它们。另外,我还给它们做了一下分类,更便于你记忆。
/01  耦合/
01  避免循环依赖
这个原则不管是在单体应用,还是分布式应用里都是非常重要的一个原则,它可以避免「big ball of mud」项目的产生。而且,如果项目里存在着过多的循环依赖,也更容易一不小心写出循环调用的代码,让整个系统陷入死循环。
02  尽量单向依赖
在满足「01」的前提下,尽量做到单向依赖可以大大降低阅读代码、排查问题时的复杂度。如果实在对上游有依赖的话,尽量通过IOC的思路来处理,用隐性依赖代替显性依赖。
如果实在没法通过IOC来解决的话,可以将依赖上游的数据在当前系统冗余一份,然后通过MQ来保持数据同步,在业务处理的时候直接使用本地的这份冗余数据。当然,这个方法的复杂度明显比上面的更高,所以还是优先考虑上面的方案。
03  避免跨层调用
在满足「1」和「2」的前提下,尽量做到避免跨层调用,可以很起到更好的封装效果。
举个最简单的反例,就拿三层架构来说,如果应用层的代码可以直接访问数据访问层,那么业务逻辑层自然会形同虚设。而且,后续一旦涉及到某数据表增加一个参数,要修改的相关调用代码可多了……这也是为什么很多维护不善的老项目越往后大家就越不敢乱动代码的主要原因之一。
/02  对象设计/
01  单一职责原则
其实我在后面会提到SOLID原则,这里为什么将单一原则单独拿出来说呢,因为我觉得它是SOLID的六大原则里最重要的,虽然它看上去最简单。
单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
Robert C. Martin《敏捷软件开发:原则、模式和实践》
只有深刻理解这个概念,你才能真正发挥面向对象编程语言的最大优势。并且,这个思路也可以运用在模块的划分上。
遵循这一原则最关键的地方在于职责的划分,很多人其实并没有掌握好正确的划分思路。因为这个的确很难,需要你对业务有深入的了解,因为职责存在于业务里。
比如,在电商系统里体现「一个商品在某个平台销售」这个业务,你可以既在「商品」类上设置「销售渠道」属性,也可以在「销售渠道」上设置「在售商品列表」属性,还可以单独设计一个「商品绑定销售渠道」的类。但是我们从单一职责原则来考虑的话,就应该选择最后一个方案。为什么呢?因为在不同的渠道销售商品,其实对商品和销售渠道本身都没有什么影响,商品还是那个商品、渠道还是那个渠道,因此这个业务不是它们的职责。
02  减少if else
这一点可能算不上传统意义上的原则吧。但是我觉得这是很容易体现开发水平高低的一点。所以也列了一下。
大部分的 if-else 都可以合理运用设计模式来消灭掉。比如, 状态模式、策略模式、命令模式、责任链模式、代理模式。
如果对这些设计模式的形态有些模糊了,那么赶紧去回顾一下。
03  数据冗余
冗余数据的确可以带来很多便利,比如减少RPC请求查询其它程序内的数据。但是副作用也是很明显的,付出了需要解决数据一致性问题为代价。因此仅当存在性能要求时,才考虑数据冗余。 
在平时的代码设计中,你可以有很多方法来降低不必要的数据冗余,比如:
  1. 给每一个API或者Function区分必要参数和可选参数。如此一来,对调用方来说能够减少为了传入可选参数而做的不必要的数据冗余以及RPC请求。
  2. 如果是会对外提供访问的API,一定要最小化参数,可以自行获取的数据尽量在内部自行获取,不要求外部传入。目的同1。
我觉得能意识到上面的这些设计原则,已经算得上是一个合格的程序员了。如果想要更近一步,还可以在以下这几个方面考虑。
/01  对象设计/
01  SOLID原则
这个原则鼎鼎大名了,应该大家都知道,就不展开说了。
  • Single Responsibility Principle:单一职责原则
  • Open Closed Principle:开闭原则
  • Liskov Substitution Principle:里氏替换原则
  • Law of Demeter:迪米特法则
  • Interface Segregation Principle:接口隔离原则
  • Dependence Inversion Principle:依赖倒置原则
我为什么将它们放到进阶里面呢,因为我觉得这里面除了单一职责,其它几个原则还兼顾着在可扩展性上的考量。所以,除了单一职责以外的原则没做到位,最多牺牲了可扩展性和一定的耦合度。但是单一职责没做好,可会存在非常大的耦合问题。
/02  数据准确性/
01  可重试
这点可能在单体应用中感受不明显。但是在分布式系统却重要得多。因为网络是不可靠的,如果设计的代码不可重试,那么会存在大量的数据不一致问题需要手动去处理。可头疼死你。
02  幂等
重视「幂等」的原因和「可重试」一样,在单体应用中作用不大,最多对瞬时的重复点击有作用。但是在不可靠网络的分布式系统中,某个请求被重复提交的可能性大大增加,如何保证多次请求的结果是一致的就至关重要了。
03  CAP、BASE
前面的「可重试」和「幂等」更多是在代码级别的数据准确性设计。如果在整个大系统层面考虑数据准确性,需要基于经典的CAP定理、BASE理论去设计。什么业务场景需要保证强一致性,什么业务场景可以接受存在延迟的最终一致性,是需要仔细考量的。
多提一句,如果采用最终一致性方案的话,尽可能地增加一个后续的核对机制,以解决某些异步消息在中途丢失、长期异常挂起等等导致的数据不一致问题。
/03  数据存储/
01  数据安全
其实,要在代码设计上考虑数据安全,只需要一些非常基础的业务意识就够了。你只要能识别到哪些数据是敏感的,针对这些数据做一些保护机制,防止数据泄漏。比如,加密、脱敏、避免越权、减少非必要传输等等。
以上的这些是我目前暂时想到的在工作中最常用的开发原则。如果后续再想到什么我会补充在评论区,也欢迎你在评论区发表你的经验之谈。
还是总结一下,这篇呢Z哥与你分享了一些我在工作中常用的开发原则。总体来说,他们分为4类。
  1. 耦合:避免循环依赖、尽量单向依赖、避免跨层调用。
  2. 对象设计:单一职责原则、减少if else、数据冗余、SOLID原则。
  3. 数据准确性:可重试、幂等、CAP、BASE。
  4. 数据存储:数据安全。

在程序设计领域, SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由罗伯特·C·马丁在21世纪早期引入,指代了面向对象编程和面向对象设计的五个基本原则。当这些原则被一起应用时,它们使得一个程序员开发一个容易进行软件维护和扩展的系统变得更加可能。

1 单一职责原则(SRP)

一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中,即又定义有且仅有一个原因使类变更。(甲类负责两个不同的职责:职责A,职责B。当由于职责A需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责B功能发生故障。也就是说职责A和B被耦合在了一起”)。

2 开放封闭原则(OCP)

实体应该对扩展是开放的,对修改是封闭的。即可扩展(extension),不可修改(modification)。

eg:

原代码,不同用户类型进行不同服务,但是后续每新增不同的用户类型,只能在下面继续加判断代码。

修改后代码,用户实现统一的接口,后续新增用户类型,只需要新增对应实现类。

3 里氏替换原则(LSP)

一个对象在其出现的任何地方,都可以用子类实例做替换,并且不会导致程序的错误。
经典的例子: 正方形不是长方形的子类。原因是正方形多了一个属性“长 == 宽”。这时,对正方形类设置不同的长和宽,计算面积的结果是最后设置那项的平方,而不是长*宽,从而发生了与长方形不一致的行为。如果程序依赖了长方形的面积计算方式,并使用正方形替换了长方形,实际表现与预期不符。

4 接口隔离原则(ISP)

接口隔离原则表明客户端不应该被强迫实现一些他们不会使用的接口,应该把胖接口中的方法分组,然后用多个接口替代它,每个接口服务于一个子模块。简单地说,就是使用多个专门的接口比使用单个接口要好很多。

ISP的主要观点如下:

1)一个类对另外一个类的依赖性应当是建立在最小的接口上的。

ISP可以达到不强迫客户(接口的使用方法)依赖于他们不用的方法,接口的实现类应该只呈现为单一职责的角色(遵循SRP原则)

ISP还可以降低客户之间的相互影响—当某个客户要求提供新的职责(需要变化)而迫使接口发生改变时,影响到其他客户程序的可能性最小。

2)客户端程序不应该依赖它不需要的接口方法(功能)。

客户端程序就应该依赖于它不需要的接口方法(功能),那依赖于什么?依赖它所需要的接口。客户端需要什么接口就是提供什么接口,把不需要的接口剔除,这就要求对接口进行细化,保证其纯洁性。

5 依赖倒置原则(DIP)

抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对抽象(接口)编程,而不是针对实现细节编程。

开闭原则(OCP)是面向对象设计原则的基础也是整个设计的一个终极目标,而依赖倒置原则(DIP )则是实现OCP原则的一个基础,换句话说开闭原则(OCP)是你盖一栋大楼的设计蓝图,那么依赖倒置原则就是盖这栋大楼的一个钢构框架。

来看一个例子假设我们在开发一个软件产品需要一个日志系统,要将系统产生的一些重要事情记录在记事本上。通常我们的实现如下:

但是随着时间的推移,产品做的好买了很多客户,产品变得越来越大,使用Logger 类的地方成千上万处,可怕的事情终于发生了:

A 客户提出来我想把日志存在数据库中便于做统计分析。

B 客户说我想把日志打印在一个控制台上便于我时时监测系统运行情况。

C 客户说我要把日志存到Windows Azure Storage上。

深度解析 Raft 分布式一致性协议

https://juejin.cn/post/6907151199141625870

笔者期望通过一篇权威靠谱、清晰易懂的系统性文章,帮助读者深入理解 Raft 算法,并能付诸于工程实践中,同时解读不易理解或容易误解的关键点。

本文是 Raft 实战系列理论内容的整合篇,我们结合 Raft 论文讲解 Raft 算法思路,并遵循 Raft 的模块化思想对难理解及容易误解的内容抽丝剥茧。算法方面讲解:选主机制、基于日志实现状态机机制、安全正确维护状态机机制;工程实现方面讲解:集群成员变更防脑裂策略、解决数据膨胀及快速恢复状态机策略、线性一致读性能优化策略等。


1. 概述

1.1 Raft 是什么?

Raft is a consensus algorithm for managing a replicated log. It produces a result equivalent to (multi-)Paxos, and it is as efficient as Paxos, but its structure is different from Paxos; this makes Raft more understandable than Paxos and also provides a better foundation for building practical systems.

–《In Search of an Understandable Consensus Algorithm》

在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性?

这里我们只讨论强一致性,即线性一致性。弱一致性涵盖的范围较广,涉及根据实际场景进行诸多取舍,不在 Raft 系列的讨论目标范围内。

所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。

共识算法(Consensus Algorithm)就是用来做这个事情的,它保证即使在小部分(≤ (N-1)/2)节点故障的情况下,系统仍然能正常对外提供服务。共识算法通常基于状态复制机(Replicated State Machine)模型,也就是所有节点从同一个 state 出发,经过同样的操作 log,最终达到一致的 state。

 图:Replicated State Machine

共识算法是构建强一致性分布式系统的基石,Paxos 是共识算法的代表,而 Raft 则是其作者在博士期间研究 Paxos 时提出的一个变种,主要优点是容易理解、易于实现,甚至关键的部分都在论文中给出了伪代码实现。

1.2 谁在使用 Raft

采用 Raft 的系统最著名的当属 etcd 了,可以认为 etcd 的核心就是 Raft 算法的实现。作为一个分布式 kv 系统,etcd 使用 Raft 在多节点间进行数据同步,每个节点都拥有全量的状态机数据。我们在学习了 Raft 以后将会深刻理解为什么 etcd 不适合大数据量的存储(for the most critical data)、为什么集群节点数不是越多越好、为什么集群适合部署奇数个节点等问题。

作为一个微服务基础设施,consul 底层使用 Raft 来保证 consul server 之间的数据一致性。在阅读完第六章后,我们会理解为什么 consul 提供了 defaultconsistentstale 三种一致性模式(Consistency Modes)、它们各自适用的场景,以及 consul 底层是如何通过改变 Raft 读模型来支撑这些不同的一致性模式的。

TiKV 同样在底层使用了 Raft 算法。虽然都自称是“分布式 kv 存储”,但 TiKV 的使用场景与 etcd 存在区别。其目标是支持 100TB+ 的数据,类似 etcd 的单 Raft 集群肯定无法支撑这个数据量。因此 TiKV 底层使用 Multi Raft,将数据划分为多个 region,每个 region 其实还是一个标准的 Raft 集群,对每个分区的数据实现了多副本高可用。

目前 Raft 在工业界已经开始大放异彩,对于其各类应用场景这里不再赘述,感兴趣的读者可以参考 这里,下方有列出各种语言的大量 Raft 实现。

1.3 Raft 基本概念

Raft 使用 Quorum 机制来实现共识和容错,我们将对 Raft 集群的操作称为提案,每当发起一个提案,必须得到大多数(> N/2)节点的同意才能提交。

这里的“提案”我们可以先狭义地理解为对集群的读写操作,“提交”理解为操作成功。

那么当我们向 Raft 集群发起一系列读写操作时,集群内部究竟发生了什么呢?我们先来概览式地做一个整体了解,接下来再分章节详细介绍每个部分。

首先,Raft 集群必须存在一个主节点(leader),我们作为客户端向集群发起的所有操作都必须经由主节点处理。所以 Raft 核心算法中的第一部分就是选主Leader election)——没有主节点集群就无法工作,先票选出一个主节点,再考虑其它事情。

其次,主节点需要承载什么工作呢?它会负责接收客户端发过来的操作请求,将操作包装为日志同步给其它节点,在保证大部分节点都同步了本次操作后,就可以安全地给客户端回应响应了。这一部分工作在 Raft 核心算法中叫日志复制Log replication)。

然后,因为主节点的责任是如此之大,所以节点们在选主的时候一定要谨慎,只有符合条件的节点才可以当选主节点。此外主节点在处理操作日志的时候也一定要谨慎,为了保证集群对外展现的一致性,不可以覆盖或删除前任主节点已经处理成功的操作日志。所谓的“谨慎处理”,其实就是在选主和提交日志的时候进行一些限制,这一部分在 Raft 核心算法中叫安全性Safety)。

Raft 核心算法其实就是由这三个子问题组成的:选主(Leader election)、日志复制(Log replication)、安全性(Safety)。这三部分共同实现了 Raft 核心的共识和容错机制。

除了核心算法外,Raft 也提供了几个工程实践中必须面对问题的解决方案。

第一个是关于日志无限增长的问题。Raft 将操作包装成为了日志,集群每个节点都维护了一个不断增长的日志序列,状态机只有通过重放日志序列来得到。但由于这个日志序列可能会随着时间流逝不断增长,因此我们必须有一些办法来避免无休止的磁盘占用和过久的日志重放。这一部分叫日志压缩Log compaction)。

第二个是关于集群成员变更的问题。一个 Raft 集群不太可能永远是固定几个节点,总有扩缩容的需求,或是节点宕机需要替换的时候。直接更换集群成员可能会导致严重的脑裂问题。Raft 给出了一种安全变更集群成员的方式。这一部分叫集群成员变更Cluster membership change)。

此外,我们还会额外讨论线性一致性的定义、为什么 Raft 不能与线性一致划等号、如何基于 Raft 实现线性一致,以及在如何保证线性一致的前提下进行读性能优化

以上便是理论篇内将会讨论到的大部分内容的概要介绍,这里我们对 Raft 已经有了一个宏观上的认识,知道了各个部分大概是什么内容,以及它们之间的关系。

接下来我们将会详细讨论 Raft 算法的每个部分。让我们先从第一部分选主开始。

2. 选主

2.1 什么是选主

选主(Leader election)就是在分布式系统内抉择出一个主节点来负责一些特定的工作。在执行了选主过程后,集群中每个节点都会识别出一个特定的、唯一的节点作为 leader。

我们开发的系统如果遇到选主的需求,通常会直接基于 zookeeper 或 etcd 来做,把这部分的复杂性收敛到第三方系统。然而作为 etcd 基础的 Raft 自身也存在“选主”的概念,这是两个层面的事情:基于 etcd 的选主指的是利用第三方 etcd 让集群对谁做主节点的决策达成一致,技术上来说利用的是 etcd 的一致性状态机、lease 以及 watch 机制,这个事情也可以改用单节点的 MySQL/Redis 来做,只是无法获得高可用性;而 Raft 本身的选主则指的是在 Raft 集群自身内部通过票选、心跳等机制来协调出一个大多数节点认可的主节点作为集群的 leader 去协调所有决策。

当你的系统利用 etcd 来写入谁是主节点的时候,这个决策也在 etcd 内部被它自己集群选出的主节点处理并同步给其它节点。

2.2 Raft 为什么要进行选主?

按照论文所述,原生的 Paxos 算法使用了一种点对点(peer-to-peer)的方式,所有节点地位是平等的。在理想情况下,算法的目的是制定一个决策,这对于简化的模型比较有意义。但在工业界很少会有系统会使用这种方式,当有一系列的决策需要被制定的时候,先选出一个 leader 节点然后让它去协调所有的决策,这样算法会更加简单快速。

此外,和其它一致性算法相比,Raft 赋予了 leader 节点更强的领导力,称之为 Strong Leader。比如说日志条目只能从 leader 节点发送给其它节点而不能反着来,这种方式简化了日志复制的逻辑,使 Raft 变得更加简单易懂。

2.3 Raft 选主过程

2.3.1 节点角色

Raft 集群中每个节点都处于以下三种角色之一:

  • Leader: 所有请求的处理者,接收客户端发起的操作请求,写入本地日志后同步至集群其它节点。
  • Follower: 请求的被动更新者,从 leader 接收更新请求,写入本地文件。如果客户端的操作请求发送给了 follower,会首先由 follower 重定向给 leader。
  • Candidate: 如果 follower 在一定时间内没有收到 leader 的心跳,则判断 leader 可能已经故障,此时启动 leader election 过程,本节点切换为 candidate 直到选主结束。

2.3.2 任期

每开始一次新的选举,称为一个任期term),每个 term 都有一个严格递增的整数与之关联。

每当 candidate 触发 leader election 时都会增加 term,如果一个 candidate 赢得选举,他将在本 term 中担任 leader 的角色。但并不是每个 term 都一定对应一个 leader,有时候某个 term 内会由于选举超时导致选不出 leader,这时 candicate 会递增 term 号并开始新一轮选举。

Term 更像是一个逻辑时钟logic clock)的作用,有了它,就可以发现哪些节点的状态已经过期。每一个节点都保存一个 current term,在通信时带上这个 term 号。

节点间通过 RPC 来通信,主要有两类 RPC 请求:

  • RequestVote RPCs: 用于 candidate 拉票选举。
  • AppendEntries RPCs: 用于 leader 向其它节点复制日志以及同步心跳。

2.3.3 节点状态转换

我们知道集群每个节点的状态都只能是 leader、follower 或 candidate,那么节点什么时候会处于哪种状态呢?下图展示了一个节点可能发生的状态转换:

接下来我们详细讨论下每个转换所发生的场景。

2.3.3.1 Follower 状态转换过程

Raft 的选主基于一种心跳机制,集群中每个节点刚启动时都是 follower 身份(Step: starts up),leader 会周期性的向所有节点发送心跳包来维持自己的权威,那么首个 leader 是如何被选举出来的呢?方法是如果一个 follower 在一段时间内没有收到任何心跳,也就是选举超时,那么它就会主观认为系统中没有可用的 leader,并发起新的选举(Step: times out, starts election)。

这里有一个问题,即这个“选举超时时间”该如何制定?如果所有节点在同一时刻启动,经过同样的超时时间后同时发起选举,整个集群会变得低效不堪,极端情况下甚至会一直选不出一个主节点。Raft 巧妙的使用了一个随机化的定时器,让每个节点的“超时时间”在一定范围内随机生成,这样就大大的降低了多个节点同时发起选举的可能性。

 图:一个五节点 Raft 集群的初始状态,所有节点都是 follower 身份,term 为 1,且每个节点的选举超时定时器不同

若 follower 想发起一次选举,follower 需要先增加自己的当前 term,并将身份切换为 candidate。然后它会向集群其它节点发送“请给自己投票”的消息(RequestVote RPC)。

 图:S1 率先超时,变为 candidate,term + 1,并向其它节点发出拉票请求

2.3.3.2 Candicate 状态转换过程

Follower 切换为 candidate 并向集群其他节点发送“请给自己投票”的消息后,接下来会有三种可能的结果,也即上面节点状态图中 candidate 状态向外伸出的三条线

1. 选举成功(Step: receives votes from majority of servers)

当candicate从整个集群的大多数(N/2+1)节点获得了针对同一 term 的选票时,它就赢得了这次选举,立刻将自己的身份转变为 leader 并开始向其它节点发送心跳来维持自己的权威。

 图:“大部分”节点都给了 S1 选票

 图:S1 变为 leader,开始发送心跳维持权威

每个节点针对每个 term 只能投出一张票,并且按照先到先得的原则。这个规则确保只有一个 candidate 会成为 leader。

2. 选举失败(Step: discovers current leader or new term)

Candidate 在等待投票回复的时候,可能会突然收到其它自称是 leader 的节点发送的心跳包,如果这个心跳包里携带的 term 不小于 candidate 当前的 term,那么 candidate 会承认这个 leader,并将身份切回 follower。这说明其它节点已经成功赢得了选举,我们只需立刻跟随即可。但如果心跳包中的 term 比自己小,candidate 会拒绝这次请求并保持选举状态。

 图:S4、S2 依次开始选举

 图:S4 成为 leader,S2 在收到 S4 的心跳包后,由于 term 不小于自己当前的 term,因此会立刻切为 follower 跟随 S4

3. 选举超时(Step: times out, new election)

第三种可能的结果是 candidate 既没有赢也没有输。如果有多个 follower 同时成为 candidate,选票是可能被瓜分的,如果没有任何一个 candidate 能得到大多数节点的支持,那么每一个 candidate 都会超时。此时 candidate 需要增加自己的 term,然后发起新一轮选举。如果这里不做一些特殊处理,选票可能会一直被瓜分,导致选不出 leader 来。这里的“特殊处理”指的就是前文所述的随机化选举超时时间

 图:S1 ~ S5 都在参与选举

 图:没有任何节点愿意给他人投票

 图:如果没有随机化超时时间,所有节点将会继续同时发起选举……

以上便是 candidate 三种可能的选举结果。

2.3.3.3 Leader 状态转换过程

节点状态图中的最后一条线是:discovers server with higher term。想象一个场景:当 leader 节点发生了宕机或网络断连,此时其它 follower 会收不到 leader 心跳,首个触发超时的节点会变为 candidate 并开始拉票(由于随机化各个 follower 超时时间不同),由于该 candidate 的 term 大于原 leader 的 term,因此所有 follower 都会投票给它,这名 candidate 会变为新的 leader。一段时间后原 leader 恢复了,收到了来自新leader 的心跳包,发现心跳中的 term 大于自己的 term,此时该节点会立刻切换为 follower 并跟随的新 leader。

上述流程的动画模拟如下:

 图:S4 作为 term2 的 leader

 图:S4 宕机,S5 即将率先超时

 图:S5 当选 term3 的 leader

 图:S4 宕机恢复后收到了来自 S5 的 term3 心跳

 图:S4 立刻变为 S5 的 follower

以上就是 Raft 的选主逻辑,但还有一些细节(譬如是否给该 candidate 投票还有一些其它条件)依赖算法的其它部分基础,我们会在后续“安全性”一章描述。

当票选出 leader 后,leader 也该承担起相应的责任了,这个责任是什么?就是下一章将介绍的“日志复制”。

3. 日志复制

3.1 什么是日志复制

在前文中我们讲过:共识算法通常基于状态复制机Replicated State Machine)模型,所有节点从同一个 state 出发,经过一系列同样操作 log 的步骤,最终也必将达到一致的 state。也就是说,只要我们保证集群中所有节点的 log 一致,那么经过一系列应用(apply)后最终得到的状态机也就是一致的。

Raft 负责保证集群中所有节点 log 的一致性

此外我们还提到过:Raft 赋予了 leader 节点更强的领导力(Strong Leader)。那么 Raft 保证 log 一致的方式就很容易理解了,即所有 log 都必须交给 leader 节点处理,并由 leader 节点复制给其它节点。

这个过程,就叫做日志复制Log replication)。

3.2 Raft 日志复制机制解析

3.2.1 整体流程解析

一旦 leader 被票选出来,它就承担起领导整个集群的责任了,开始接收客户端请求,并将操作包装成日志,并复制到其它节点上去。

整体流程如下:

  • Leader 为客户端提供服务,客户端的每个请求都包含一条即将被状态复制机执行的指令。
  • Leader 把该指令作为一条新的日志附加到自身的日志集合,然后向其它节点发起附加条目请求AppendEntries RPC),来要求它们将这条日志附加到各自本地的日志集合。
  • 当这条日志已经确保被安全的复制,即大多数(N/2+1)节点都已经复制后,leader 会将该日志 apply 到它本地的状态机中,然后把操作成功的结果返回给客户端。

整个集群的日志模型可以宏观表示为下图(x ← 3 代表 x 赋值为 3):

每条日志除了存储状态机的操作指令外,还会拥有一个唯一的整数索引值log index)来表明它在日志集合中的位置。此外,每条日志还会存储一个 term 号(日志条目方块最上方的数字,相同颜色 term 号相同),该 term 表示 leader 收到这条指令时的当前任期,term 相同的 log 是由同一个 leader 在其任期内发送的。

当一条日志被 leader 节点认为可以安全的 apply 到状态机时,称这条日志是 committed(上图中的 committed entries)。那么什么样的日志可以被 commit 呢?答案是:当 leader 得知这条日志被集群过半的节点复制成功时。因此在上图中我们可以看到 (term3, index7) 这条日志以及之前的日志都是 committed,尽管有两个节点拥有的日志并不完整。

Raft 保证所有 committed 日志都已经被持久化,且“最终”一定会被状态机apply。

注:这里的“最终”用词很微妙,它表明了一个特点:Raft 保证的只是集群内日志的一致性,而我们真正期望的集群对外的状态机一致性需要我们做一些额外工作,这一点在《线性一致性与读性能优化》一章会着重介绍。

3.2.2 日志复制流程图解

我们通过 Raft 动画 来模拟常规日志复制这一过程:

如上图,S1 当选 leader,此时还没有任何日志。我们模拟客户端向 S1 发起一个请求。

S1 收到客户端请求后新增了一条日志 (term2, index1),然后并行地向其它节点发起 AppendEntries RPC。

S2、S4 率先收到了请求,各自附加了该日志,并向 S1 回应响应。

所有节点都附加了该日志,但由于 leader 尚未收到任何响应,因此暂时还不清楚该日志到底是否被成功复制。

当 S1 收到2个节点的响应时,该日志条目的边框就已经变为实线,表示该日志已经安全的复制,因为在5节点集群中,2个 follower 节点加上 leader 节点自身,副本数已经确保过半,此时 S1 将响应客户端的请求

leader 后续会持续发送心跳包给 followers,心跳包中会携带当前已经安全复制(我们称之为 committed)的日志索引,此处为 (term2, index1)。

所有 follower 都通过心跳包得知 (term2, index1) 的 log 已经成功复制 (committed),因此所有节点中该日志条目的边框均变为实线。

3.2.3 对日志一致性的保证

前边我们使用了 (term2, index1) 这种方式来表示一条日志条目,这里为什么要带上 term,而不仅仅是使用 index?原因是 term 可以用来检查不同节点间日志是否存在不一致的情况,阅读下一节后会更容易理解这句话。

Raft 保证:如果不同的节点日志集合中的两个日志条目拥有相同的 term 和 index,那么它们一定存储了相同的指令。

为什么可以作出这种保证?因为 Raft 要求 leader 在一个 term 内针对同一个 index 只能创建一条日志,并且永远不会修改它。

同时 Raft 也保证:如果不同的节点日志集合中的两个日志条目拥有相同的 term 和 index,那么它们之前的所有日志条目也全部相同。

这是因为 leader 发出的 AppendEntries RPC 中会额外携带上一条日志的 (term, index),如果 follower 在本地找不到相同的 (term, index) 日志,则拒绝接收这次新的日志

所以,只要 follower 持续正常地接收来自 leader 的日志,那么就可以通过归纳法验证上述结论。

3.2.4 可能出现的日志不一致场景

在所有节点正常工作的时候,leader 和 follower的日志总是保持一致,AppendEntries RPC 也永远不会失败。然而我们总要面对任意节点随时可能宕机的风险,如何在这种情况下继续保持集群日志的一致性才是我们真正要解决的问题。

上图展示了一个 term8 的 leader 刚上任时,集群中日志可能存在的混乱情况。例如 follower 可能缺少一些日志(a ~ b),可能多了一些未提交的日志(c ~ d),也可能既缺少日志又多了一些未提交日志(e ~ f)。

注:Follower 不可能比 leader 多出一些已提交(committed)日志,这一点是通过选举上的限制来达成的,会在下一章《安全性》介绍。

我们先来尝试复现上述 a ~ f 场景,最后再讲 Raft 如何解决这种不一致问题。

场景a~b. Follower 日志落后于 leader

这种场景其实很简单,即 follower 宕机了一段时间,follower-a 从收到 (term6, index9) 后开始宕机,follower-b 从收到 (term4, index4) 后开始宕机。这里不再赘述。

场景c. Follower 日志比 leader 多 term6

当 term6 的 leader 正在将 (term6, index11) 向 follower 同步时,该 leader 发生了宕机,且此时只有 follower-c 收到了这条日志的 AppendEntries RPC。然后经过一系列的选举,term7 可能是选举超时,也可能是 leader 刚上任就宕机了,最终 term8 的 leader 上任了,成就了我们看到的场景 c。

场景d. Follower 日志比 leader 多 term7

当 term6 的 leader 将 (term6, index10) 成功 commit 后,发生了宕机。此时 term7 的 leader 走马上任,连续同步了两条日志给 follower,然而还没来得及 commit 就宕机了,随后集群选出了 term8 的 leader。

场景e. Follower 日志比 leader 少 term5 ~ 6,多 term4

当 term4 的 leader 将 (term4, index7) 同步给 follower,且将 (term4, index5) 及之前的日志成功 commit 后,发生了宕机,紧接着 follower-e 也发生了宕机。这样在 term5~7 内发生的日志同步全都被 follower-e 错过了。当 follower-e 恢复后,term8 的 leader 也刚好上任了。

场景f. Follower 日志比 leader 少 term4 ~ 6,多 term2 ~ 3

当 term2 的 leader 同步了一些日志(index4 ~ 6)给 follower 后,尚未来得及 commit 时发生了宕机,但它很快恢复过来了,又被选为了 term3 的 leader,它继续同步了一些日志(index7~11)给 follower,但同样未来得及 commit 就又发生了宕机,紧接着 follower-f 也发生了宕机,当 follower-f 醒来时,集群已经前进到 term8 了。

3.2.5 如何处理日志不一致

通过上述场景我们可以看到,真实世界的集群情况很复杂,那么 Raft 是如何应对这么多不一致场景的呢?其实方式很简单暴力,想想 Strong Leader 这个词。

Raft 强制要求 follower 必须复制 leader 的日志集合来解决不一致问题。

也就是说,follower 节点上任何与 leader 不一致的日志,都会被 leader 节点上的日志所覆盖。这并不会产生什么问题,因为某些选举上的限制,如果 follower 上的日志与 leader 不一致,那么该日志在 follower 上一定是未提交的。未提交的日志并不会应用到状态机,也不会被外部的客户端感知到。

要使得 follower 的日志集合跟自己保持完全一致,leader 必须先找到二者间最后一次达成一致的地方。因为一旦这条日志达成一致,在这之前的日志一定也都一致(回忆下前文)。这个确认操作是在 AppendEntries RPC 的一致性检查步骤完成的。

Leader 针对每个 follower 都维护一个 next index,表示下一条需要发送给该follower 的日志索引。当一个 leader 刚刚上任时,它初始化所有 next index 值为自己最后一条日志的 index+1。但凡某个 follower 的日志跟 leader 不一致,那么下次 AppendEntries RPC 的一致性检查就会失败。在被 follower 拒绝这次 Append Entries RPC 后,leader 会减少 next index 的值并进行重试。

最终一定会存在一个 next index 使得 leader 和 follower 在这之前的日志都保持一致。极端情况下 next index 为1,表示 follower 没有任何日志与 leader 一致,leader 必须从第一条日志开始同步。

针对每个 follower,一旦确定了 next index 的值,leader 便开始从该 index 同步日志,follower 会删除掉现存的不一致的日志,保留 leader 最新同步过来的。

整个集群的日志会在这个简单的机制下自动趋于一致。此外要注意,leader 从来不会覆盖或者删除自己的日志,而是强制 follower 与它保持一致。

这就要求集群票选出的 leader 一定要具备“日志的正确性”,这也就关联到了前文提到的:选举上的限制。

下一章我们将对此详细讨论。

4. 安全性及正确性

前面的章节我们讲述了 Raft 算法是如何选主和复制日志的,然而到目前为止我们描述的这套机制还不能保证每个节点的状态机会严格按照相同的顺序 apply 日志。想象以下场景:

  1. Leader 将一些日志复制到了大多数节点上,进行 commit 后发生了宕机。
  2. 某个 follower 并没有被复制到这些日志,但它参与选举并当选了下一任 leader。
  3. 新的 leader 又同步并 commit 了一些日志,这些日志覆盖掉了其它节点上的上一任 committed 日志。
  4. 各个节点的状态机可能 apply 了不同的日志序列,出现了不一致的情况。

因此我们需要对“选主+日志复制”这套机制加上一些额外的限制,来保证状态机的安全性,也就是 Raft 算法的正确性。

4.1 对选举的限制

我们再来分析下前文所述的 committed 日志被覆盖的场景,根本问题其实发生在第2步。Candidate 必须有足够的资格才能当选集群 leader,否则它就会给集群带来不可预料的错误。Candidate 是否具备这个资格可以在选举时添加一个小小的条件来判断,即:

每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。

Candidate 想要赢得选举成为 leader,必须得到集群大多数节点的投票,那么它的日志就一定至少不落后于大多数节点。又因为一条日志只有复制到了大多数节点才能被 commit,因此能赢得选举的 candidate 一定拥有所有 committed 日志

因此前一篇文章我们才会断定地说:Follower 不可能比 leader 多出一些 committed 日志。

比较两个 (term, index) 的逻辑非常简单:如果 term 不同 term 更大的日志更新,否则 index 大的日志更新。

4.2 对提交的限制

除了对选举增加一点限制外,我们还需对 commit 行为增加一点限制,来完成我们 Raft 算法核心部分的最后一块拼图。

回忆下什么是 commit:

当 leader 得知某条日志被集群过半的节点复制成功时,就可以进行 commit,committed 日志一定最终会被状态机 apply。

所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。

然而 leader 并不能在任何时候都随意 commit 旧任期留下的日志,即使它已经被复制到了大多数节点。Raft 论文给出了一个经典场景:

上图从左到右按时间顺序模拟了问题场景。

阶段a:S1 是 leader,收到请求后将 (term2, index2) 只复制给了 S2,尚未复制给 S3 ~ S5。

阶段b:S1 宕机,S5 当选 term3 的 leader(S3、S4、S5 三票),收到请求后保存了 (term3, index2),尚未复制给任何节点。

阶段c:S5 宕机,S1 恢复,S1 重新当选 term4 的 leader,继续将 (term2, index2) 复制给了 S3,已经满足大多数节点,我们将其 commit。

阶段d:S1 又宕机,S5 恢复,S5 重新当选 leader(S2、S3、S4 三票),将 (term3, inde2) 复制给了所有节点并 commit。注意,此时发生了致命错误,已经 committed 的 (term2, index2) 被 (term3, index2) 覆盖了。

为了避免这种错误,我们需要添加一个额外的限制:

Leader 只允许 commit 包含当前 term 的日志。

针对上述场景,问题发生在阶段c,即使作为 term4 leader 的 S1 将 (term2, index2) 复制给了大多数节点,它也不能直接将其 commit,而是必须等待 term4 的日志到来并成功复制后,一并进行 commit。

阶段e:在添加了这个限制后,要么 (term2, index2) 始终没有被 commit,这样 S5 在阶段d将其覆盖就是安全的;要么 (term2, index2) 同 (term4, index3) 一起被 commit,这样 S5 根本就无法当选 leader,因为大多数节点的日志都比它新,也就不存在前边的问题了。

以上便是对算法增加的两个小限制,它们对确保状态机的安全性起到了至关重要的作用。

至此我们对 Raft 算法的核心部分,已经介绍完毕。下一章我们会介绍两个同样描述于论文内的辅助技术:集群成员变更和日志压缩,它们都是在 Raft 工程实践中必不可少的部分。

5. 集群成员变更与日志压缩

尽管我们已经通过前几章了解了 Raft 算法的核心部分,但相较于算法理论来说,在工程实践中仍有一些现实问题需要我们去面对。Raft 非常贴心的在论文中给出了两个常见问题的解决方案,它们分别是:

  1. 集群成员变更:如何安全地改变集群的节点成员。
  2. 日志压缩:如何解决日志集合无限制增长带来的问题。

本文我们将分别讲解这两种技术。

5.1 集群成员变更

在前文的理论描述中我们都假设了集群成员是不变的,然而在实践中有时会需要替换宕机机器或者改变复制级别(即增减节点)。一种最简单暴力达成目的的方式就是:停止集群、改变成员、启动集群。这种方式在执行时会导致集群整体不可用,此外还存在手工操作带来的风险。

为了避免这样的问题,Raft 论文中给出了一种无需停机的、自动化的改变集群成员的方式,其实本质上还是利用了 Raft 的核心算法,将集群成员配置作为一个特殊日志从 leader 节点同步到其它节点去。

5.1.1 直接切换集群成员配置

先说结论:所有将集群从旧配置直接完全切换到新配置的方案都是不安全的

因此我们不能想当然的将新配置直接作为日志同步给集群并 apply。因为我们不可能让集群中的全部节点在“同一时刻原子地切换其集群成员配置,所以在切换期间不同的节点看到的集群视图可能存在不同,最终可能导致集群存在多个 leader。

为了理解上述结论,我们来看一个实际出现问题的场景,下图对其进行了展现。

 图5-1

阶段a. 集群存在 S1 ~ S3 三个节点,我们将该成员配置表示为 C-old,绿色表示该节点当前视图(成员配置)为 C-old,其中红边的 S3 为 leader。

阶段b. 集群新增了 S4、S5 两个节点,该变更从 leader 写入,我们将 S1 ~ S5 的五节点新成员配置表示为 C-new,蓝色表示该节点当前视图为 C-new。

阶段c. 假设 S3 短暂宕机触发了 S1 与 S5 的超时选主。

阶段d. S1 向 S2、S3 拉票,S5 向其它全部四个节点拉票。由于 S2 的日志并没有比 S1 更新,因此 S2 可能会将选票投给 S1,S1 两票当选(因为 S1 认为集群只有三个节点)。而 S5 肯定会得到 S3、S4 的选票,因为 S1 感知不到 S4,没有向它发送 RequestVote RPC,并且 S1 的日志落后于 S3,S3 也一定不会投给 S1,结果 S5 三票当选。最终集群出现了多个主节点的致命错误,也就是所谓的脑裂。

 图5-2

上图来自论文,用不同的形式展现了和图5-1相同的问题。颜色代表的含义与图5-1是一致的,在 problem: two disjoint majorities 所指的时间点,集群可能会出现两个 leader。

但是,多主问题并不是在任何新老节点同时选举时都一定可能出现的,社区一些文章在举多主的例子时可能存在错误,下面是一个案例(笔者学习 Raft 协议也从这篇文章中受益匪浅,应该是作者行文时忽略了。文章很赞,建议大家参考学习):

来源:zhuanlan.zhihu.com/p/27207160

 图5-3

该假想场景类似图5-1的阶段d,模拟过程如下:

  1. S1 为集群原 leader,集群新增 S4、S5,该配置被推给了 S3,S2 尚未收到。
  2. 此时 S1 发生短暂宕机,S2、S3 分别触发选主。
  3. 最终 S2 获得了 S1 和自己的选票,S3 获得了 S4、S5 和自己的选票,集群出现两个 leader。

图5-3过程看起来好像和图5-1没有什么大的不同,只是参与选主的节点存在区别,然而事实是图5-3的情况是不可能出现的

注意:Raft 论文中传递集群变更信息也是通过日志追加实现的,所以也受到选主的限制。很多读者对选主限制中比较的日志是否必须是 committed 产生疑惑,回看下在《安全性》一文中的描述:

每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。

这里再帮大家明确下,论文里确实间接表明了,选主时比较的日志是不要求 committed 的,只需比较本地的最新日志就行

回到图5-3,不可能出现的原因在于,S1 作为原 leader 已经第一个保存了新配置的日志,而 S2 尚未被同步这条日志,根据上一章《安全性》我们讲到的选主限制S1 不可能将选票投给 S2,因此 S2 不可能成为 leader。

5.1.2 两阶段切换集群成员配置

Raft 使用一种两阶段方法平滑切换集群成员配置来避免遇到前一节描述的问题,具体流程如下:

阶段一

  1. 客户端将 C-new 发送给 leader,leader 将 C-old 与 C-new 取并集并立即apply,我们表示为 C-old,new
  2. Leader 将 C-old,new 包装为日志同步给其它节点。
  3. Follower 收到 C-old,new 后立即 apply,当 **C-old,new 的大多数节点(即 C-old 的大多数节点和 C-new 的大多数节点)**都切换后,leader 将该日志 commit。

阶段二

  1. Leader 接着将 C-new 包装为日志同步给其它节点。
  2. Follower 收到 C-new 后立即 apply,如果此时发现自己不在 C-new 列表,则主动退出集群。
  3. Leader 确认 C-new 的大多数节点都切换成功后,给客户端发送执行成功的响应。

上图展示了该流程的时间线。虚线表示已经创建但尚未 commit 的成员配置日志,实线表示 committed 的成员配置日志。

为什么该方案可以保证不会出现多个 leader?我们来按流程逐阶段分析。

阶段1. C-old,new 尚未 commit

该阶段所有节点的配置要么是 C-old,要么是 C-old,new,但无论是二者哪种,只要原 leader 发生宕机,新 leader 都必须得到大多数 C-old 集合内节点的投票

以图5-1场景为例,S5 在阶段d根本没有机会成为 leader,因为 C-old 中只有 S3 给它投票了,不满足大多数。

阶段2. C-old,new 已经 commit,C-new 尚未下发

该阶段 C-old,new 已经 commit,可以确保已经被 C-old,new 的大多数节点(再次强调:C-old 的大多数节点和 C-new 的大多数节点)复制。

因此当 leader 宕机时,新选出的 leader 一定是已经拥有 C-old,new 的节点,不可能出现两个 leader。

阶段3. C-new 已经下发但尚未 commit

该阶段集群中可能有三种节点 C-old、C-old,new、C-new,但由于已经经历了阶段2,因此 C-old 节点不可能再成为 leader。而无论是 C-old,new 还是 C-new 节点发起选举,都需要经过大多数 C-new 节点的同意,因此也不可能出现两个 leader。

阶段4. C-new 已经 commit

该阶段 C-new 已经被 commit,因此只有 C-new 节点可以得到大多数选票成为 leader。此时集群已经安全地完成了这轮变更,可以继续开启下一轮变更了。

以上便是对该两阶段方法可行性的分步验证,Raft 论文将该方法称之为共同一致Joint Consensus)。

关于集群成员变更另一篇更详细的论文还给出了其它方法,简单来说就是论证一次只变更一个节点的的正确性,并给出解决可用性问题的优化方案。感兴趣的同学可以参考:《Consensus: Bridging Theory and Practice》

5.2 日志压缩

我们知道 Raft 核心算法维护了日志的一致性,通过 apply 日志我们也就得到了一致的状态机,客户端的操作命令会被包装成日志交给 Raft 处理。然而在实际系统中,客户端操作是连绵不断的,但日志却不能无限增长,首先它会占用很高的存储空间,其次每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机。

因此 Raft 提供了一种机制去清除日志里积累的陈旧信息,叫做日志压缩

快照Snapshot)是一种常用的、简单的日志压缩方式,ZooKeeper、Chubby 等系统都在用。简单来说,就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志就都可以丢弃了。所以大家对“压缩”一词不要产生错误理解,我们并没有办法将状态机快照“解压缩”回日志序列。

注意,在 Raft 中我们只能为 committed 日志做 snapshot,因为只有 committed 日志才是确保最终会应用到状态机的。

上图展示了一个节点用快照替换了 (term1, index1) ~ (term3, index5) 的日志。

快照一般包含以下内容:

  1. 日志的元数据:最后一条被该快照 apply 的日志 term 及 index
  2. 状态机:前边全部日志 apply 后最终得到的状态机

当 leader 需要给某个 follower 同步一些旧日志,但这些日志已经被 leader 做了快照并删除掉了时,leader 就需要把该快照发送给 follower。

同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader 也可以直接发送快照,大量节约日志传输和回放时间。

同步快照使用一个新的 RPC 方法,叫做 InstallSnapshot RPC

至此我们已经将 Raft 论文中的内容基本讲解完毕了。《In Search of an Understandable Consensus Algorithm (Extended Version)》 毕竟只有18页,更加侧重于理论描述而非工程实践。如果你想深入学习 Raft,或自己动手写一个靠谱的 Raft 实现,《Consensus: Bridging Theory and Practice》 是你参考的不二之选。

接下来我们将额外讨论一下关于线性一致性和 Raft 读性能优化的内容。

6. 线性一致性与读性能优化

6.1 什么是线性一致性?

在该系列首篇《基本概念》中我们提到过:在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性

什么是一致性?所谓一致性有很多种模型,不同的模型都是用来评判一个并发系统正确与否的不同程度的标准。而我们今天要讨论的是强一致性(Strong Consistency)模型,也就是线性一致性(Linearizability),我们经常听到的 CAP 理论中的 C 指的就是它。

其实我们在第一篇就已经简要描述过何为线性一致性:

所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。

像单机一样提供服务”从感官上描述了一个线性一致性系统应该具备的特性,那么我们该如何判断一个系统是否具备线性一致性呢?通俗来说就是不能读到旧(stale)数据,但具体分为两种情况:

  • 对于调用时间存在重叠(并发)的请求,生效顺序可以任意确定。
  • 对于调用时间存在先后关系(偏序)的请求,后一个请求不能违背前一个请求确定的结果。

只要根据上述两条规则即可判断一个系统是否具备线性一致性。下面我们来看一个非线性一致性系统的例子。

本节例图均来自《Designing Data-Intensive Application》,作者 Martin Kleppmann

如上图所示,裁判将世界杯的比赛结果写入了主库,Alice 和 Bob 所浏览的页面分别从两个不同的从库读取,但由于存在主从同步延迟,Follower 2 的本次同步延迟高于 Follower 1,最终导致 Bob 听到了 Alice 的惊呼后刷新页面看到的仍然是比赛进行中。

虽然线性一致性的基本思想很简单,只是要求分布式系统看起来只有一个数据副本,但在实际中还是有很多需要关注的点,我们继续看几个例子。

上图从客户端的外部视角展示了多个用户同时请求读写一个系统的场景,每条柱形都是用户发起的一个请求,左端是请求发起的时刻,右端是收到响应的时刻。由于网络延迟和系统处理时间并不固定,所以柱形长度并不相同。

  • x 最初的值为 0,Client C 在某个时间段将 x 写为 1
  • Client A 第一个读操作位于 Client C 的写操作之前,因此必须读到原始值 0
  • Client A 最后一个读操作位于 Client C 的写操作之后,如果系统是线性一致的,那么必须读到新值 1
  • 其它与写操作重叠的所有读操作,既可能返回 0,也可能返回 1,因为我们并不清楚写操作在哪个时间段内哪个精确的点生效,这种情况下读写是并发的。

仅仅是这样的话,仍然不能说这个系统满足线性一致。假设 Client B 的第一次读取返回了 1,如果 Client A 的第二次读取返回了 0,那么这种场景并不破坏上述规则,但这个系统仍不满足线性一致,因为客户端在写操作执行期间看到 x 的值在新旧之间来回翻转,这并不符合我们期望的“看起来只有一个数据副本”的要求。

所以我们需要额外添加一个约束,如下图所示。

在任何一个客户端的读取返回新值后,所有客户端的后续读取也必须返回新值,这样系统便满足线性一致了。

我们最后来看一个更复杂的例子,继续细化这个时序图。

如上图所示,每个读写操作在某个特定的时间点都是原子性的生效,我们在柱形中用竖线标记出生效的时间点,将这些标记按时间顺序连接起来。那么线性一致的要求就是:连线总是按照时间顺序向右移动,而不会向左回退。所以这个连线结果必定是一个有效的寄存器读写序列:任何客户端的每次读取都必须返回该条目最近一次写入的值。

线性一致性并非限定在分布式环境下,在单机单核系统中可以简单理解为“寄存器”的特性。

Client B 的最后一次读操作并不满足线性一致,因为在连线向右移动的前提下,它读到的值是错误的(因为Client A 已经读到了由 Client C 写入的 4)。此外这张图里还有一些值得指出的细节点,可以解开很多我们在使用线性一致系统时容易产生的误解:

  • Client B 的首个读请求在 Client D 的首个写请求和 Client A 的首个写请求之前发起,但最终读到的却是最后由 Client A 写成功之后的结果。
  • Client A 尚未收到首个写请求成功的响应时,Client B 就读到了 Client A 写入的值。

上述现象在线性一致的语义下都是合理的。

所以线性一致性(Linearizability)除了叫强一致性(Strong Consistency)外,还叫做原子一致性(Atomic Consistency)、立即一致性(Immediate Consistency)或外部一致性(External Consistency),这些名字看起来都是比较贴切的。

6.2 Raft 线性一致性读

在了解了什么是线性一致性之后,我们将其与 Raft 结合来探讨。首先需要明确一个问题,使用了 Raft 的系统都是线性一致的吗?不是的,Raft 只是提供了一个基础,要实现整个系统的线性一致还需要做一些额外的工作。

假设我们期望基于 Raft 实现一个线性一致的分布式 kv 系统,让我们从最朴素的方案开始,指出每种方案存在的问题,最终使整个系统满足线性一致性。

6.2.1 写主读从缺陷分析

写操作并不是我们关注的重点,如果你稍微看了一些理论部分就应该知道,所有写操作都要作为提案从 leader 节点发起,当然所有的写命令都应该简单交给 leader 处理。真正关键的点在于读操作的处理方式,这涉及到整个系统关于一致性方面的取舍

在该方案中我们假设读操作直接简单地向 follower 发起,那么由于 Raft 的 Quorum 机制(大部分节点成功即可),针对某个提案在某一时间段内,集群可能会有以下两种状态:

  • 某次写操作的日志尚未被复制到一少部分 follower,但 leader 已经将其 commit。
  • 某次写操作的日志已经被同步到所有 follower,但 leader 将其 commit 后,心跳包尚未通知到一部分 follower。

以上每个场景客户端都可能读到过时的数据,整个系统显然是不满足线性一致的。

6.2.2 写主读主缺陷分析

在该方案中我们限定,所有的读操作也必须经由 leader 节点处理,读写都经过 leader 难道还不能满足线性一致?是的!! 并且该方案存在不止一个问题!!

问题一:状态机落后于 committed log 导致脏读

回想一下前文讲过的,我们在解释什么是 commit 时提到了写操作什么时候可以响应客户端:

所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。

也就是说一个提案只要被 leader commit 就可以响应客户端了,Raft 并没有限定提案结果在返回给客户端前必须先应用到状态机。所以从客户端视角当我们的某个写操作执行成功后,下一次读操作可能还是会读到旧值。

这个问题的解决方式很简单,在 leader 收到读命令时我们只需记录下当前的 commit index,当 apply index 追上该 commit index 时,即可将状态机中的内容响应给客户端。

问题二:网络分区导致脏读

假设集群发生网络分区,旧 leader 位于少数派分区中,而且此刻旧 leader 刚好还未发现自己已经失去了领导权,当多数派分区选出了新的 leader 并开始进行后续写操作时,连接到旧 leader 的客户端可能就会读到旧值了。

因此,仅仅是直接读 leader 状态机的话,系统仍然不满足线性一致性。

6.2.3 Raft Log Read

为了确保 leader 处理读操作时仍拥有领导权,我们可以将读请求同样作为一个提案走一遍 Raft 流程,当这次读请求对应的日志可以被应用到状态机时,leader 就可以读状态机并返回给用户了。

这种读方案称为 Raft Log Read,也可以直观叫做 Read as Proposal

为什么这种方案满足线性一致?因为该方案根据 commit index 对所有读写请求都一起做了线性化,这样每个读请求都能感知到状态机在执行完前一写请求后的最新状态,将读写日志一条一条的应用到状态机,整个系统当然满足线性一致。但该方案的缺点也非常明显,那就是性能差,读操作的开销与写操作几乎完全一致。而且由于所有操作都线性化了,我们无法并发读状态机。

6.3 Raft 读性能优化

接下来我们将介绍几种优化方案,它们在不违背系统线性一致性的前提下,大幅提升了读性能。

6.3.1 Read Index

与 Raft Log Read 相比,Read Index 省掉了同步 log 的开销,能够大幅提升读的吞吐一定程度上降低读的时延。其大致流程为:

  1. Leader 在收到客户端读请求时,记录下当前的 commit index,称之为 read index。
  2. Leader 向 followers 发起一次心跳包,这一步是为了确保领导权,避免网络分区时少数派 leader 仍处理请求。
  3. 等待状态机至少应用到 read index(即 apply index 大于等于 read index)。
  4. 执行读请求,将状态机中的结果返回给客户端。

这里第三步的 apply index 大于等于 read index 是一个关键点。因为在该读请求发起时,我们将当时的 commit index 记录了下来,只要使客户端读到的内容在该 commit index 之后,那么结果一定都满足线性一致(如不理解可以再次回顾下前文线性一致性的例子以及2.2中的问题一)。

6.3.2 Lease Read

与 Read Index 相比,Lease Read 进一步省去了网络交互开销,因此更能显著降低读的时延

基本思路是 leader 设置一个比选举超时(Election Timeout)更短的时间作为租期,在租期内我们可以相信其它节点一定没有发起选举,集群也就一定不会存在脑裂,所以在这个时间段内我们直接读主即可,而非该时间段内可以继续走 Read Index 流程,Read Index 的心跳包也可以为租期带来更新。

Lease Read 可以认为是 Read Index 的时间戳版本,额外依赖时间戳会为算法带来一些不确定性,如果时钟发生漂移会引发一系列问题,因此需要谨慎的进行配置。

6.3.3 Follower Read

在前边两种优化方案中,无论我们怎么折腾,核心思想其实只有两点:

  • 保证在读取时的最新 commit index 已经被 apply。
  • 保证在读取时 leader 仍拥有领导权。

这两个保证分别对应2.2节所描述的两个问题。

其实无论是 Read Index 还是 Lease Read,最终目的都是为了解决第二个问题。换句话说,读请求最终一定都是由 leader 来承载的。

那么读 follower 真的就不能满足线性一致吗?其实不然,这里我们给出一个可行的读 follower 方案:Follower 在收到客户端的读请求时,向 leader 询问当前最新的 commit index,反正所有日志条目最终一定会被同步到自己身上,follower 只需等待该日志被自己 commit 并 apply 到状态机后,返回给客户端本地状态机的结果即可。这个方案叫做 Follower Read

注意:Follower Read 并不意味着我们在读过程中完全不依赖 leader 了,在保证线性一致性的前提下完全不依赖 leader 理论上是不可能做到的。


以上就是 Raft 算法的核心内容及工程实践最需要考虑的内容。

如果你坚持看了下来,相信已经对 Raft 算法的理论有了深刻的理解。当然,理论和工程实践之间存在的鸿沟可能比想象的还要大,实践中有众多的细节问题需要去面对。在后续的源码分析及实践篇中,我们会结合代码讲解到许多理论部分没有提到的这些细节点,并介绍基础架构设计的诸多经验,敬请期待!

[转]Unicode、UTF-8、UTF-16,终于懂了

https://mp.weixin.qq.com/s/dIuTohi2CLkmOe1skGVf4w

计算机起源于美国,上个世纪,他们对英语字符与二进制位之间的关系做了统一规定,并制定了一套字符编码规则,这套编码规则被称为ASCII编码

ASCII 编码一共定义了128个字符的编码规则,用七位二进制表示 ( 0x00 – 0x7F ), 这些字符组成的集合就叫做 ASCII 字符集

随着计算机的普及,在不同的地区和国家又出现了很多字符编码,比如: 大陆的 GB2312、港台的 BIG5, 日本的 Shift JIS等等

由于字符编码不同,计算机在不同国家之间的交流变得很困难,经常会出现乱码的问题,比如:对于同一个二进制数据,不同的编码会解析出不同的字符

当互联网迅猛发展,地域限制打破之后,人们迫切的希望有一种统一的规则, 对所有国家和地区的字符进行编码,于是 Unicode 就出现了

Unicode 简介

Unicode 是国际标准字符集,它将世界各种语言的每个字符定义一个唯一的编码,以满足跨语言、跨平台的文本信息转换

Unicode 字符集的编码范围是 0x0000 – 0x10FFFF , 可以容纳一百多万个字符, 每个字符都有一个独一无二的编码,也即每个字符都有一个二进制数值和它对应,这里的二进制数值也叫 码点 , 比如:汉字 “中” 的 码点是 0x4E2D, 大写字母 A 的码点是 0x41, 具体字符对应的 Unicode 编码可以查询 Unicode字符编码表

字符集和字符编码

字符集是很多个字符的集合,例如 GB2312 是简体中文的字符集,它收录了六千多个常用的简体汉字及一些符号,数字,拼音等字符

字符编码是 字符集的一种实现方式,把字符集中的字符映射为特定的字节或字节序列,它是一种规则

比如:Unicode 只是字符集,UTF-8、UTF-16、UTF-32 才是真正的字符编码规则

Unicode 字符存储

Unicode 是一个符号集, 它只规定了每个符号的二进制值,但是符号具体如何存储它并没有规定

前面提到, Unicode 字符集的编码范围是 0x0000 – 0x10FFFF,因此需要 1 到 3 个字节来表示

那么,对于三个字节的 Unicode字符,计算机怎么知道它表示的是一个字符而不是三个字符呢 ?

如果所有字符都用三个字节表示,那么对于那些一个字节就能表示的字符来说,有两个字节是无意义的,对于存储来说,这是极大的浪费,假如 , 一个普通的文本, 大部分字符都只需一个字节就能表示,现在如果需要三个字节才能表示,文本的大小会大出三倍左右

因此,Unicode 出现了多种存储方式,常见的有 UTF-8、UTF-16、UTF-32,它们分别用不同的二进制格式来表示 Unicode 字符

UTF-8、UTF-16、UTF-32 中的 “UTF” 是 “Unicode Transformation Format” 的缩写,意思是”Unicode 转换格式”,后面的数 字表明至少使用多少个比特位来存储字符, 比如:UTF-8 最少需要8个比特位也就是一个字节来存储,对应的, UTF-16 和 UTF-32 分别需要最少 2 个字节 和 4 个字节来存储

UTF-8 编码

UTF-8: 是一种变长字符编码,被定义为将码点编码为 1 至 4 个字节,具体取决于码点数值中有效二进制位的数量

UTF-8 的编码规则:

  1. 对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的, 所以 UTF-8 能兼容 ASCII 编码,这也是互联网普遍采用 UTF-8 的原因之一
  1. 对于 n 字节的符号( n > 1),第一个字节的前 n 位都设为 1,第 n + 1 位设为 0,后面字节的前两位一律设为 10 。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码

下表是Unicode编码对应UTF-8需要的字节数量以及编码格式

Unicode编码范围(16进制) UTF-8编码方式(二进制)
000000 – 00007F 0xxxxxxx ASCII码
000080 – 0007FF 110xxxxx 10xxxxxx
000800 – 00FFFF 1110xxxx 10xxxxxx 10xxxxxx
01 0000 – 10 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

表格中第一列是Unicode编码的范围,第二列是对应UTF-8编码方式,其中红色的二进制 “1” 和 “0” 是固定的前缀, 字母 x 表示可用编码的二进制位

根据上面表格,要解析 UTF-8 编码就很简单了,如果一个字节第一位是 0 ,则这个字节就是一个单独的字符,如果第一位是 1 ,则连续有多少个 1 ,就表示当前字符占用多少个字节

下面以 “中” 字 为例来说明 UTF-8 的编码,具体的步骤如下图, 为了便于说明,图中左边加了 1,2,3,4 的步骤编号

图片

首先查询 “中” 字的 Unicode 码 0x4E2D, 转成二进制, 总共有 16 个二进制位, 具体如上图 步骤1 所示

通过前面的 Unicode 编码和 UTF-8 编码的表格知道,Unicode 码 0x4E2D 对应 000800 – 00FFFF 的范围,所以, “中” 字的 UTF-8 编码 需要 3 个字节,即格式是 1110xxxx 10xxxxxx 10xxxxxx

然后从 “中” 字的最后一个二进制位开始,按照从后向前的顺序依次填入格式中的 x 字符,多出的二进制补为 0, 具体如上图 步骤2、步骤3 所示

于是,就得到了 “中” 的 UTF-8 编码是 11100100 10111000 10101101, 转换成十六进制就是 0xE4B8AD, 具体如上图 步骤4 所示

UTF-16 编码

UTF-16 也是一种变长字符编码, 这种编码方式比较特殊, 它将字符编码成 2 字节 或者 4 字节

具体的编码规则如下:

  1. 对于 Unicode 码小于 0x10000 的字符, 使用 2 个字节存储,并且是直接存储 Unicode 码,不用进行编码转换
  1. 对于 Unicode 码在 0x10000 和 0x10FFFF 之间的字符,使用 4 个字节存储,这 4 个字节分成前后两部分,每个部分各两个字节,其中,前面两个字节的前 6 位二进制固定为 110110,后面两个字节的前 6 位二进制固定为 110111, 前后部分各剩余 10 位二进制表示符号的 Unicode 码 减去 0x10000 的结果
  1. 大于 0x10FFFF 的 Unicode 码无法用 UTF-16 编码

下表是Unicode编码对应UTF-16编码格式

Unicode编码范围(16进制) 具体Unicode码(二进制) UTF-16编码方式(二进制) 字节
0000 0000 – 0000 FFFF xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 2
0001 0000 – 0010 FFFF yy yyyyyyyy xx xxxxxxxx 110110yy yyyyyyyy 110111xx xxxxxxxx 4

表格中第一列是Unicode编码的范围,第二列是 具体Unicode码的二进制 ( 第二行的第二列表示的是 Unicode 码 减去 0x10000 后的二进制 ) , 第三列是对应UTF-16编码方式,其中红色的二进制 “1” 和 “0” 是固定的前缀, 字母 x 和 y 表示可用编码的二进制位, 第四列表示 编码占用的字节数

前面提到过,”中” 字的 Unicode 码是 4E2D, 它小于 0x10000,根据表格可知,它的 UTF-16 编码占两个字节,并且和 Unicode 码相同,所以 “中” 字的 UTF-16 编码为 4E2D

我从 Unicode字符表网站 找了一个老的南阿拉伯字母, 它的 Unicode 码是: 0x10A6F , 可以访问 https://unicode-table.com/cn/10A6F/ 查看字符的说明, Unicode 码对应的字符如下图所示

图片

下面以这个 老的南阿拉伯字母的 Unicode 码 0x10A6F 为例来说明 UTF-16 4 字节的编码,具体步骤如下,为了便于说明,图中左边加了 1,2,3,4 、5的步骤编号

图片

首先把 Unicode 码 0x10A6F 转成二进制, 对应上图的 步骤 1

然后把 Unicode 码 0x10A6F 减去 0x10000, 结果为 0xA6F 并把这个值转成二进制 00 00000010 10 01101111,对应上图的 步骤 2

然后 从二进制 00 00000010 10 01101111 的最后一个二进制为开始,按照从后向前的顺序依次填入格式中的 x 和 y 字符,多出的二进制补为 0, 对应上图的 步骤 3、 步骤 4

于是,就计算出了 Unicode 码 0x10A6F 的 UTF-16 编码是 11011000 00000010 11011110 01101111 , 转换成十六进制就是 0xD802DE6F, 对应上图的 步骤 5

UTF-32 编码

UTF-32 是固定长度的编码,始终占用 4 个字节,足以容纳所有的 Unicode 字符,所以直接存储 Unicode 码即可,不需要任何编码转换。虽然浪费了空间,但提高了效率。

UTF-8、UTF-16、UTF-32 之间如何转换

前面介绍过,UTF-8、UTF-16、UTF-32 是 Unicode 码表示成不同的二进制格式的编码规则,同样,通过这三种编码的二进制表示,也能获得对应的 Unicode 码,有了字符的 Unicode 码,按照上面介绍的 UTF-8、UTF-16、UTF-32 的编码方法 就能转换成任一种编码了

UTF 字节序

最小编码单元是多字节才会有字节序的问题存在,UTF-8 最小编码单元是一字节,所以 它是没有字节序的问题,UTF-16 最小编码单元是 2 个字节,在解析一个 UTF-16 字符之前,需要知道每个编码单元的字节序

比如:前面提到过,”中” 字的 Unicode 码是 4E2D, “ⵎ” 字符的 Unicode 码是 2D4E, 当我们收到一个 UTF-16 字节流 4E2D 时,计算机如何识别它表示的是字符 “中” 还是 字符 “ⵎ” 呢 ?

所以,对于多字节的编码单元,需要有一个标记显式的告诉计算机,按照什么样的顺序解析字符,也就是字节序,字节序分为 大端字节序 和 小端字节序

小端字节序简写为 LE( Little-Endian ), 表示 低位字节在前,高位字节在后, 高位字节保存在内存的高地址端,而低位字节保存在内存的低地址端

大端字节序简写为 BE( Big-Endian ), 表示 高位字节在前,低位字节在后,高位字节保存在内存的低地址端,低位字节保存在在内存的高地址端

下面以 0x4E2D 为例来说明大端和小端,具体参见下图:

图片

数据是从高位字节到低位字节显示的,这也更符合人们阅读数据的习惯,而内存地址是从低地址向高地址增加

所以,字符0x4E2D 数据的高位字节是 4E,低位字节是 2D

按照大端字节序的高位字节保存内存低地址端的规则,4E 保存到低内存地址 0x10001 上,2D 则保存到高内存地址 0x10002 上

对于小端字节序,则正好相反,数据的高位字节保存到内存的高地址端,低位字节保存到内存低地址端的,所以 4E 保存到高内存地址 0x10002 上,2D 则保存到低内存地址 0x10001 上

BOM

BOM 是 byte-order mark 的缩写,是 “字节序标记” 的意思, 它常被用来当做标识文件是以 UTF-8、UTF-16 或 UTF-32 编码的标记

在 Unicode 编码中有一个叫做 “零宽度非换行空格” 的字符 ( ZERO WIDTH NO-BREAK SPACE ), 用字符 FEFF 来表示

对于 UTF-16 ,如果接收到以 FEFF 开头的字节流, 就表明是大端字节序,如果接收到 FFFE, 就表明字节流 是小端字节序

UTF-8 没有字节序问题,上述字符只是用来标识它是 UTF-8 文件,而不是用来说明字节顺序的。”零宽度非换行空格” 字符 的 UTF-8 编码是 EF BB BF, 所以如果接收到以 EF BB BF 开头的字节流,就知道这是UTF-8 文件

下面的表格列出了不同 UTF 格式的固定文件头

UTF编码 固定文件头
UTF-8 EF BB BF
UTF-16LE FF FE
UTF-16BE FE FF
UTF-32LE FF FE 00 00
UTF-32BE 00 00 FE FF

根据上面的 固定文件头,下面列出了 “中” 字在文件中的存储 ( 包含文件头 )

编码 固定文件头
Unicode 编码 0X004E2D
UTF-8 EF BB BF 4E 2D
UTF-16BE FE FF 4E 2D
UTF-16LE FF FE 2D 4E
UTF-32BE 00 00 FE FF 00 00 4E 2D
UTF-32LE FF FE 00 00 2D 4E 00 00

常见的字符编码的问题

  • Redis 中文key的显示

有时候我们需要向redis中写入含有中文的数据,然后在查看数据,但是会看到一些其他的字符,而不是我们写入的中文

图片

上图中,我们向redis 写入了一个 “中” 字,通过 get 命令查看的时候无法显示我们写入的 “中” 字

这时候加一个 –raw 参数,重新启动 redis-cli 即可,也即 执行 redis-cli –raw 命令启动redis客户端,具体的如下图所示

图片

  • MySQL 中的 utf8 和 utf8mb4

MySQL 中的 “utf8” 实际上不是真正的 UTF-8, “utf8” 只支持每个字符最多 3 个字节, 对于超过 3 个字节的字符就会出错, 而真正的 UTF-8 至少要支持 4 个字节

MySQL 中的 “utf8mb4” 才是真正的 UTF-8

下面以 test 表为例来说明, 表结构如下:

mysql> show create table test\G
*************************** 1. row ***************************
       Table: test
Create Table: CREATE TABLE `test` (
  `name` char(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

向 test 表分别插入 “中” 字 和 Unicode 码为 0x10A6F 的字符,这个字符需要从 https://unicode-table.com/cn/10A6F/ 直接复制到 MySQL 控制台上,手工输入会无效,具体的执行结果如下图:

图片

从上图可以看出,插入 “中” 字 成功,插入 0x10A6F 字符失败,错误提示无效的字符串,\xF0\X90\XA9\xAF 正是 0x10A6F 字符的 UTF-8 编码,占用 4 个字节, 因为 MySQL 的 utf8 编码最多只支持 3 个字节,所以插入会失败

把 test 表的字符集改成 utf8mb4 , 排序规则 改成 utf8bm4_unicode_ci, 具体如下图所示:

图片

字符集和排序方式修改之后,再次插入 0x10A6F 字符, 结果是成功的,具体执行结果如下图所示

图片

上图中,set names utf8mb4 是为了测试方便,临时修改当前会话的字符集,以便保持和 服务器一致,实际解决这个问题需要修改 my.cnf 配置中 服务器和客户端的字符集

小结

本文从字符编码的历史介绍了 Unicode 出现的原因,接着介绍了 Unicode 字符集中 三种不同的编码方式:UTF-8、UTF-16、UTF-32 以及它们的的编码方法,紧接着介绍了 字节序、BOM ,最后讲到了字符集在 MySQL 和 Redis 应用中常见的问题以及解决方案 ,更多关于 Unicode 的介绍请参考 Unicode 的 RFC 文档。

最多能创建多少个TCP连接?

https://mp.weixin.qq.com/s/mGkf-9LZhhUgSIRBRqfRDw

低并发编程
战略上藐视技术,战术上重视技术
本文坚持看到结尾才有动图
气不气?
我是一个 Linux 服务器上的进程,名叫小进。
老是有人说我最多只能创建 65535 个 TCP 连接。
我不信这个邪,今天我要亲自去实践一下。
我走到操作系统老大的跟前,说:
“老操,我要建立一个 TCP 连接!”
老操不慌不忙,拿出一个表格递给我,”小进,先填表吧”
图片
我一看这个表,这不就是经典的 socket 四元组嘛。我只有一块网卡,其 IP 地址是 123.126.45.68,我想要与 110.242.68.3 的 80 端口建立一个 TCP 连接,我将这些信息填写在了表中。
图片
源端口号填什么呢?我记得端口号是 16 位的,可以有 0 ~ 65535 这个范围的数字,那我随便选一个吧!
正当我犹豫到底选什么数字的时候,老操一把抢过我的表格。
“你墨迹个啥呢小进?源端口号不用你填,我会给你分配一个可用的数字。源IP也不用你填,我知道都有哪些网卡,并且会帮你选个合适的。真是个新手,回去等消息吧。”
“哦”
老操带着我的表格,走了。
过了很长时间,老操终于回来了,并且带着一个纸条。
图片
“小进,你把这个收好了。”
我问道,”这是啥呀?”
老操不耐烦地说道,”刚刚说你是新手你还不服,这个 5 表示文件描述符,linux 下一切皆文件,你待会和你那个目标 IP 进行 TCP 通信的时候,就对着这个文件描述符读写就好啦。”
“这么方便!好的,谢谢老操。”
我拿着这个文件描述符,把它放到属于我的内存中裱起来了,反正我只是想看看最多能创建多少 TCP 连接,又不是去真的用它,嘻嘻。
 
端口号
 
过了一分钟,我又去找老操了。
“老操,我要建立一个 TCP 连接!”
老操不慌不忙,拿出一个表格递给我,”小进,先填表吧”
图片
这回我熟悉了,只把目标IP和目标端口填好。
图片
老操办好事之后,又带着一个纸条回来,上面写着数字”6″。
就这样,我每隔一分钟都去找老操建立一个新的 TCP 连接,目标 IP 都是110.242.68.3,目标端口都是 80。
老操也很奇怪,不知道我在这折腾啥,他虽然权力大,但无权拒绝我的指令,每次都兢兢业业地把事情办好,并给我一张一张写着文件描述符的纸条。
直到有一次,我收到的纸条有些不同。
图片
我带着些许责怪的语气问,”老操,这是怎么回事呀?”
老操也没好气地说,”这表示端口号不够用啦!早就觉得你小子不对劲了,一个劲地对着同一个 IP 和端口创建 TCP 连接,之前没办法必须执行你给的指令,现在不行了,端口号不够用了,源端口那里我没法给你填了。”
我也不是那么好骗的,质疑道。”老操,你也别欺负我这个新手,我可是知道端口号是 16 位的,范围是 1~65535,一共可以创建 65535 个 TCP 连接,我现在才创建了 63977 个,怎么就不够了!”
老操鄙视地看了我一眼,”你小子可真是闲的蛋疼啊,还真一个个数,来我告诉你吧,Linux 对可使用的端口范围是有具体限制的,具体可以用如下命令查看。”
[root]# cat /proc/sys/net/ipv4/ip_local_port_range 
1024 65000
“看到没,当前的限制是1024~65000,所以你就只能有63977个端口号可以使用。”
图片
我赶紧像老操道歉,”哎哟真是抱歉,还是我见识太少,那这个数可以修改么?”
老操也没跟我一般见识,还是耐心地回答我,”可以的,具体可以 vim /etc/sysctl.conf 这个文件进行修改,我们在这个文件里添加一行记录”
net.ipv4.ip_local_port_range = 60000 60009
“保存好后执行 sysctl -p /etc/sysctl.conf 使其生效。这样你就只有 10 个端口号可以用了,就会更快报出端口号不够用的错误”
“原来如此,谢谢老操又给我上了一课。”
哎不对,建立一个 TCP 连接,需要将通信两端的套接字(socket)进行绑定,如下:
源 IP 地址:源端口号 <—->  目标 IP 地址:目标端口号
只要这套绑定关系构成的四元组不重复即可,刚刚端口号不够用了,是因为我一直对同一个目标IP和端口建立连接,那我换一个目标端口号试试。
图片
我又把这个表交给老操,老操一眼就看破了我的小心思,可是也没办法,马上去给我建立了一个新的TCP连接,并且成功返回给我一个新的文件描述符纸条。
看来成功了,只要源端口号不用够用了,就不断变换目标 IP 和目标端口号,保证四元组不重复,我就能创建好多好多 TCP 连接啦!
这也证明了有人说最多只能创建 65535 个TCP连接是多么荒唐。
 
文件描述符
 
找到了突破端口号限制的办法,我不断找老操建立TCP连接,老操也拿我没有办法。
直到有一次,我又收到了一张特殊的纸条,上面写的不是文件描述符。
图片
我又没好气地问老操,”这又是咋回事?”
老操幸灾乐祸地告诉我,”呵呵,你小子以为突破端口号限制就无法无天了?现在文件描述符不够用啦!”
“怎么啥啥都有限制啊?你们操作系统给我们的限制也太多了吧?”
“废话,你看看你都建了多少个TCP连接了!每建立一个TCP连接,我就得分配给你一个文件描述符,linux 对可打开的文件描述符的数量分别作了三个方面的限制。”

系统级:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看

用户级:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看

进程级:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看

图片

天呢,真是人在屋檐下呀,我赶紧看了看这些具体的限制。
[root ~]# cat /proc/sys/fs/file-max
100000
[root ~]# cat /proc/sys/fs/nr_open
100000
[root ~]# cat /etc/security/limits.conf
...
* soft nproc 100000
* hard nproc 100000
原来如此,我记得刚刚收到的最后一张纸条是。
图片
再之后就收到文件描述符不够的错误了。
我又请教老操,”老操,那这个限制可以修改么?”
老操仍然耐心地告诉我,”当然可以,比如你想修改单个进程可打开的最大文件描述符限制为100,可以这样。”
echo 100 > /proc/sys/fs/nr_open
“原来如此,我这就去把各种文件描述符限制都改大一点,也不多,就在后面加个0吧”
“额,早知道不告诉你小子了。”老操再次用鄙视的眼睛看着我。
 
线程
 
突破了文件描述符限制,我又开始肆无忌惮地创建起了TCP连接。
但我发现,老操的办事效率越来越慢,建立一个TCP连接花的时间越来越久。
有一次,我忍不住责问老操,”你是不是在偷懒啊?之前找你建一个TCP连接就花不到一分钟时间,你看看最近我哪次不是等一个多小时你才搞好?”
老操也忍不住了,”小进啊你还好意思说我,你知不知道你每建一个TCP连接都需要消耗一个线程来为你服务?现在我和CPU老大那里都忙得不可开交了,一直在为你这好几十万个线程不停地进行上下文切换,我们精力有限啊,自然就没法像以前那么快为你服务了。”
图片
听完老操的抱怨,我想起了之前似乎有人跟我说过 C10K 问题,就是当服务器连接数达到 1 万且每个连接都需要消耗一个线程资源时,操作系统就会不停地忙于线程的上下文切换,最终导致系统崩溃,这可不是闹着玩的。
我赶紧像操作系统老大请教,”老操,实在不好意思,一直以为你强大无比,没想到也有忙得不可开交的时候呀,那我们现在应该怎么办呀?”
老操无奈地说,”我劝你还是别再继续玩了,没什么意义,不过我想你也不会听我的,那我跟你说两句吧。”
你现在这种每建一个TCP连接就创建一个线程的方式,是最传统的多线程并发模型,早期的操作系统也只支持这种方式。但现在我进化了,我还支持 IO 多路复用的方式,简单说就是一个线程可以管理多个 TCP 连接的资源,这样你就可以用少量的线程来管理大量的 TCP 连接了。
图片
我一脸疑惑,”啥是 IO 多路复用啊?”。
老操一脸鄙视,”你这… 你去看看闪客的《你管这破玩意叫 IO 多路复用》,就明白了。”
这次真是大开眼界了,我赶紧把代码改成了这种 IO 多路复用的模型,将原来的 TCP 连接销毁掉,改成同一个线程管理多个 TCP 连接,很快,操作系统老大就恢复了以往的办事效率,同时我的 TCP 连接数又多了起来。
 
内存
 
突破了端口号、文件描述符、线程数等重重限制的我,再次肆无忌惮地创建起了TCP连接。
直到有一次,我又收到了一张红牌。
图片
嗨,又是啥东西限制了呀,改了不就完了。我不耐烦地问老操,”这回又是啥毛病?”
老操说道。”这个错误叫内存溢出,每个TCP连接本身,以及这个连接所用到的缓冲区,都是需要占用一定内存的,现在内存已经被你占满了,不够用了,所以报了这个错。”
图片
我看这次老操特别耐心,也没多说什么,但想着被内存限制住了,有点不太开心,于是我让老操帮我最后一个忙。
“老操呀,帮小进我最后一个忙吧,你权利大,你看看把那些特别占内存的进程给杀掉,给我腾出点地方,我今天要完成我的梦想,看看TCP连接数到底能创建多少个!”
老操见我真的是够拼的,便答应了我,杀死了好多进程,我很是感动。
 
CPU
 
有了老操为我争取的内存资源,我又开始日以继日地创建TCP连接。
老操也不再说什么,同样日以继日地执行着我的指令。
有一次,老操语重心长地对我说,”差不多了,我劝你就此收手吧,现在 CPU 的占用率已经快到 100% 了。”
图片
我觉得老操这人真的可笑,经过这几次的小挫折,我明白了只要思想不滑坡,方法总比苦难多,老操这人就是太谨慎了,我岂能半途而废,不管他。
我仍然继续创建着 TCP 连接。
直到有一天,老操把我请到一个小饭馆,一块吃了顿饭,吃好后说道。”咱哥俩也算是配合了很久啦,今天我是来跟你道个别的。”
我很不解地问,”怎么了老操,发生什么事了?。”
老操说,”由于你的 TCP 连接,CPU 占用率已经很长时间维持在 100%,我们的使用者,也就是我们的上帝,几乎什么事情都做不了了,连鼠标动一下都要等好久,所以他给我下达了一个重启的指令,我执行这个指令后,你,以及像你一样的所有进程,包括我这个操作系统本身,一切都就消失了。”
我大惊失色,”啊,这么突然么?这条指令什么时候执行?”
老操缓缓起身,”就现在了,刚刚这条指令还没得到 CPU 运行的机会,不过现在到了。”
突然,我眼前一黑,一切都没了。
 
总结
 
图片
资源 一台Linux服务器的资源 一个TCP连接占用的资源 占满了会发生什么
CPU 看你花多少钱买的 看你用它干嘛 电脑卡死
内存 看你花多少钱买的 取决于缓冲区大小 OOM
临时端口号 ip_local_port_range 1 cannot assign requested address
文件描述符 fs.file-max 1 too many open files
进程\线程数 ulimit -n 看IO模型 系统崩溃
后记

其实这个问题,我觉得结论不重要,最重要的是思考过程。

而思考过程其实相当简单,就是,寻找限制条件而已,其实一开始这篇文章,我写了个故事在开头,但后来感觉放在后记更合适。故事是这样的。

闪客:小宇,我问你,你一天最多能吃多少个汉堡?

小宇:额,你这问的太隐私了吧,不过看在你教我技术的份上,我就告诉你,最多能吃 4 个左右吧。

闪客:咳咳真的么?好吧,那你一分钟最多能吃多少个汉堡?

小宇:快的话可能 2 个,不过正常应该最多就能吃完 1 个了。

闪客:好的,那我问你,刚刚这两个问题你为什么能不假思索地回答出来呢?

小宇:哈哈你这是什么话,我自己我当然了解了。

闪客:不,你仔细想想你回答这两个问题的逻辑。

小宇:哦我明白你的意思了,当你问我一天最多能吃多少个汉堡时,我考虑的是我的胃的容量最多能容下多少个汉堡。而当你问我一分钟最多能吃多少个汉堡时,我考虑的时我吃汉堡的速度,按照这个速度在一分钟内能吃多少。

闪客:没错,你总结得很好!一天最多吃多少个汉堡,此时时间非常充裕,所以主要是胃的容量限制了这个汉堡最大值,计算公式应该是:

最多汉堡数 = 胃的容量 ÷ 汉堡的体积

图片

而一分钟最多吃多少个汉堡,此时胃的容量非常充裕,限制汉堡最大值的是时间因素,计算公式是:

最多汉堡数 = 一分钟 ÷ 吃一个汉堡的耗时

图片

所以,取决于最先触达的那个限制条件。

而最大 TCP 连接数这个问题,假如面试被问到了,即使你完全不会,也应该有这样的思路。

而如果你有了这样的思路,你多多少少都能回答出让面试官满意的答案,因为计算机很多时候,更看重思路,而不是细枝末节。