dream

一个菜鸟程序员的成长历程

0%

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

MCP

在AI技术大火的当下,各个公司都在搞AI-Agent技术。对于Agent来说,其实还处于一个早期的阶段,没有所谓的最佳实践。更没有一些标准规则。可谓是一片蓝海。

大厂们也都在做着自己的一些尝试。但是总有人在建设底层规则。

MCP就是一个标准化的产物。它定义了一些标准

Model Context Protocol(MCP)是由Anthropic于2024年11月推出的开放标准,旨在为大型语言模型(LLM)应用提供统一的上下文交互接口,使其能够与外部数据源和工具进行无缝集成。​MCP采用客户端-主机-服务器架构,基于JSON-RPC 2.0协议,支持有状态连接和功能协商,允许AI模型访问文件、执行函数和处理上下文提示。

截至2025年4月,MCP已被多个AI平台和开发工具采纳,包括OpenAI、Google DeepMind、Replit、Sourcegraph等。​其应用场景涵盖了软件开发、企业助手、自然语言数据访问等领域。

架构

Model Context Protocol (MCP) 遵循客户端-主机-服务器架构,每个主机可以运行多个客户端实例。此架构使用户能够在应用程序中集成 AI 功能,同时保持明确的安全边界和隔离关注点。基于 JSON-RPC,MCP 提供了一个有状态的会话协议,专注于上下文交换和客户端与服务器之间的采样协调。

MCP架构

为什么使用MCP?

为什么要有MCP这个东西呢?我不用它是否可以?

当然可以。MCP更像是一个USB接口的作用。不使用的话会有一些麻烦而已。我们来看两个案例。

案例一:USB接口案例

我们先想象一下如果电脑没有USB接口,会怎么样?

每个品牌的鼠标键盘显示器接口都不一样,比如

  • 罗技鼠标键盘使用罗技接口
  • 牧马人鼠标键盘使用牧马人接口
  • 雷蛇鼠标键盘使用雷蛇接口
  • 等等,每个品牌有自己的接口

那现在我们买了一台戴尔的电脑,这个电脑只支持戴尔的接口和罗技的接口,这样就导致我们只能买这两个品牌的鼠标键盘显示器了。

像下面这个图,我如果家里是华硕显示器,但是我买了一个戴尔的电脑,就用不了了,很不方便。

MCP架构

案例二:充电接口案例

现在很现实的一个问题就是苹果手机的充电接口。它的充电接口和安卓的充电接口是不一样的,因此两个手机的充电器无法共用。

如果你从安卓换到苹果,你需要重新买一个充电器,如果你家里两个人用的是一个安卓一个苹果,那么你们出门需要带两个充电器。。。

MCP架构

案例三:Agent案例

经过上面两个案例,我们应该明白了MCP解决了什么问题,也明白了为什么要使用MCP这种标准化协议。

那么我们再看看对于Agent而言,MCP到底干了什么呢?

我们当前Agent的现状如下:
我们实现了一个查询天气信息,并根据下雨下雪刮风等进行邮件通知预警的Agent。我们的Agent需要调用2个接口:

  1. 获取天气信息
  2. 发送邮件

我们知道,这两个接口是不一样的,因此,我们需要实现两套调用逻辑

这样是很不方便的。甚至,如果说第一个获取天气信息的接口不好使了,我们要进行切换,其他的获取天气信息的接口也需要重新实现

但是,如果我们使用了MCP,那么我们只需要一套逻辑即可了。

MCP组件

接下来我们来了解一下MCP有哪些内容,包含什么东西。

  • 主机:也就是你的Agent,或者大模型。严格来说,这个并不属于MCP,因为这个是你必备的一个东西。只不过我们的Agent需要支持MCP才可以。
  • MCP服务器:轻量级程序,每个程序都通过标准化的模型上下文协议公开特定的功能。MCP服务器就是一个提供接口程序的服务器程序,可以简单的理解为HTTP服务器,提供一些接口。比如我们上面提到的两个接口都放到这个服务器里面。
  • MCP客户端:这个是重中之重。客户端程序负责调度主机MCP服务器进行交互。

资源

资源表示 MCP 服务器希望提供给客户端的任何类型的数据。这可以包括:

  • 文件内容
  • 数据库记录
  • API 响应
  • 实时系统数据
  • 屏幕截图和图像
  • 日志文件
  • 更多

资源主要用来读取文件内容,比如我们经常使用大模型的时候会上传文件,还有使用Idea的时候可以直接让大模型获取我们的代码文件内容。

资源可以包含两种类型的内容:

  • 文本资源
    • 源代码
    • 配置文件
    • 日志文件
    • JSON/XML数据
    • 纯文本
  • 二进制资源
    • 图像
    • PDF
    • 音频
    • 视频
    • 其他非文本格式

MCP服务器需要实现下面的接口来支持资源功能

  • 资源列表接口:接口地址必须是resources/list,返回内容需要包含4个信息,这个接口会告诉Agent我们拥有哪些资源
    1
    2
    3
    4
    5
    6
    {
    uri: string; //资源的URI
    name: string; //资源的名称,方便人类看
    description?: string; //可选的资源描述
    mimeType?: string; //可选的资源类型
    }
  • 获取资源接口:接口地址必须是resources/read, 返回内容必须是以下格式,这个接口就是当Agent需要获取资源内容的时候进行调用的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    contents: [
    {
    uri: string; //资源的URI
    mimeType?: string; //可选的资源类型

    // 根据资源类型进行返回的信息:
    text?: string; // 文本资源的内容
    blob?: string; // 二进制资源的内容
    }
    ]
    }
  • 资源列表更新接口:接口地址必须是notifications/resources/list_changed,这个接口是当我们能获取的资源列表有变化的时候,服务器来通知客户端说列表改变了,你需要重新获取一下。
  • 资源内容更新接口:
    • 客户端进行资源订阅,接口地址必须是resources/subscribe
    • 当资源内容改变的时候,服务器会发送notifications/resources/updated来通知客户端资源内容变化了
    • 客户端可以重新调用获取资源接口来获取最新内容。
    • 客户端可以取消资源订阅,接口地址必须是resources/unsubscribe

提示

提示这个功能也是比较常见的,当你输入/以后,会出现一些提示的命令,很多大模型都支持这么做了。

通过提示这个功能,我们自己的Agent也可以轻松支持这个功能了。

提示符使服务器能够定义可重用的提示模板和工作流,客户端可以轻松地向用户和 LLM 显示。它们提供了一种强大的方式来标准化和共享常见的 LLM 交互。

MCP服务器需要支持以下接口:

  • 提示列表接口:接口地址prompts/list.展示提示列表。返回内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    prompts: [
    {
    name: "analyze-code", // 提示名称
    description: "Analyze code for potential improvements", //提示描述
    arguments: [ // 提示需要的参数列表
    {
    name: "language", //参数名称
    description: "Programming language", //参数描述
    required: true //是否必填
    }
    ]
    }
    ]
    }
  • 使用提示接口:接口地址prompts/get,服务器会返回一些信息如下,客户端可以将这个信息喂给服务器。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    description: "Analyze Python code for potential improvements",
    messages: [
    {
    role: "user",
    content: {
    type: "text",
    text: "Please analyze the following Python code for potential improvements:\n\n```python\ndef calculate_sum(numbers):\n total = 0\n for num in numbers:\n total = total + num\n return total\n\nresult = calculate_sum([1, 2, 3, 4, 5])\nprint(result)\n```"
    }
    }
    ]
    }

工具

工具功能是MCP的核心功能,作用就是我们一开始讲的那个。通过这个功能我们可以接入所有支持MCP的工具。

工具是模型上下文协议(MCP)中的一个强大的原语,它使服务器能够向客户端公开可执行功能。通过工具,LLM 可以与外部系统交互,执行计算,并在真实的世界中采取行动。

MCP服务器需要实现以下接口:

  • 工具列表接口:接口地址是tools/list,作用是获取MCP服务器支持的所有工具列表。可以返回如下内容:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    name: "github_create_issue", //工具名称
    description: "Create a GitHub issue", // 工具描述
    inputSchema: { // 输入参数
    type: "object", // 参数类型
    properties: { //详细的请求参数名称和类型
    title: { type: "string" },
    body: { type: "string" },
    labels: { type: "array", items: { type: "string" } }
    }
    }
    }
  • 工具调用接口:接口地址是tools/call,作用是调用指定的工具。客户端同样可以把调用工具的结果喂给大模型。

采样

采样是一个强大的 MCP 功能,允许服务器通过客户端请求 LLM 完成,在维护安全和隐私的同时实现复杂的代理行为。

采样流程如下:

  1. 服务器请求客户端的接口sampling/createMessage
  2. 客户端检查请求并可以修改它
  3. 客户端获取大模型的样本
  4. 客户端复审样本信息
  5. 客户端将结果返回给服务器

服务器可以指定要采样的大模型,如果客户端没有这个大模型,服务器还可以指定一些模型偏好,也就是一些优先级,如果没有这个大模型,就按照服务器指定的优先级来选择一些其他的大模型进行采样。

模型偏好设置

  • hits:模型名称建议的数组,客户端可以使用它来选择合适的模型,客户端可以将提示映射到来自不同提供程序的等效模型,多个提示按优先级顺序进行评估
  • costPriority: 降低成本的重要性,低成本的模型优先级更高
  • speedPriority: 低延迟响应的重要性,速度更快的模型优先级更高
  • intelligencePriority:高级模型功能的重要性,功能强大的模型优先级更高

是 MCP 中的一个概念,它定义了服务器可以操作的边界。它们为客户端提供了一种方法,可以将相关资源及其位置通知服务器。

根是客户端建议服务器应该关注的 URI。当客户端连接到服务器时,它声明服务器应该使用哪些根。虽然主要用于文件系统路径,但根可以是任何有效的 URI,包括 HTTP URL。

根有几个重要的用途:

  • 指导 :它们通知服务器相关资源和位置
  • 清晰度 :根目录清楚地表明哪些资源是您工作空间的一部分
  • 组织 :多个根允许您同时使用不同的资源

当客户端支持根时,它:

  • 在连接期间声明功能
  • 向服务器提供建议的根目录列表
  • 根目录更改时通知服务器(如果支持)

虽然根是信息性的,并不严格执行,但服务器应该:

  • 尊重提供的根
  • 使用根 URI 定位和访问资源
  • 优先考虑根边界内的操作

根通常用于定义:

  • 项目目录
  • 存储库位置
  • API 端点
  • 配置目录
  • 资源边界

客户端应该返回如下根信息

1
2
3
4
5
6
7
8
9
10
11
12
{
"roots": [
{
"uri": "file:///home/user/projects/frontend",
"name": "Frontend Repository"
},
{
"uri": "https://api.example.com/v1",
"name": "API Endpoint"
}
]
}

MCP服务器

MCP服务器作为接口的提供方。需要遵循MCP协议本身的一些规则。

需要支持上面的一些功能,主要是支持工具。至于资源和提示功能可以不支持。

MCP客户端

MCP客户端作为接口的调用方。也需要遵循MCP协议本身的一些规则。

可以选择支持采样功能。

MCP客户端还需要调用MCP服务器和大模型进行沟通。

MCP通信协议

所有传输都使用 JSON-RPC2.0 来交换消息。

JSON-RPC2.0

JSON-RPC 2.0是一个轻量级的远程过程调用(RPC)协议。它使用JSON格式的数据进行通信,这使得它非常易于理解和操作。下面是对JSON-RPC 2.0协议的详细解释以及案例说明:

在JSON-RPC 2.0中,通信的基本单位是请求和响应。客户端发送请求给服务器,服务器处理请求后返回响应。

一个典型的JSON-RPC 2.0请求格式如下:

1
2
3
4
5
6
7
8
9
{
"jsonrpc": "2.0", // 协议版本,固定2.0即可
"method": "methodName", //要调用的方法名称,可以简单理解为接口地址
"params": { //请求参数
"param1": "value1",
"param2": "value2"
},
"id": 1 // 请求的唯一标识
}

服务器处理请求以后,通常会返回以下两个格式:

  • 正确返回的格式。
    1
    2
    3
    4
    5
    {
    "jsonrpc": "2.0", // 协议版本,固定2.0即可
    "result": "Success", //成功的结果
    "id": 1 // 请求的唯一标识,要和请求的id是一样的,代表是这个请求的返回
    }
  • 如果出现错误,需要返回错误码和错误信息。
    1
    2
    3
    4
    5
    6
    7
    8
    {
    "jsonrpc": "2.0", // 协议版本,固定2.0即可
    "error": { // 错误信息,包括错误码和错误描述
    "code": -32601,
    "message": "Method not found"
    },
    "id": 1 // 请求的唯一标识,要和请求的id是一样的,代表是这个请求的返回
    }

除了正常的请求和返回以外,还有一种通知类型。作为单向消息从客户端发送到服务器,反之亦然。接收方不得发送响应。

1
2
3
4
5
6
7
{
"jsonrpc": "2.0",
"method": "methodName",
"params?": { // 请求参数
[key: string]: unknown
}
}

MCP传输方式

MCP 使用JSON-RPC对消息进行编码。JSON-RPC消息必须是 UTF-8 编码的。

该协议目前为客户端-服务器通信定义了两种标准传输机制:

  • STDIO: 通过标准输入输出来进行传输,通常用在MCP客户端和服务器都在同一主机的情况下。
  • HTTP:通过HTTP来进行传输,这允许MCP客户端和服务器不在同一主机。

客户端应尽可能支持stdio传输。

STDIO

  • 客户端将 MCP服务器作为子进程启动。
  • 服务器从其标准输入(stdin)读取 JSON-RPC 消息,并将消息发送到其标准输出(stdout)。
  • 消息可以是 JSON-RPC 请求、通知、响应或 JSON-RPC 包含一个或多个请求和/或通知的批处理。
  • 消息由换行符分隔,并且不能包含嵌入的换行符。
  • 服务器可以将 UTF-8 字符串写入其标准错误(stderr)以进行日志记录。客户端可以捕获、转发或忽略此日志记录。
  • 服务器不能向它的 stdout 写入任何不是有效的 MCP 消息。
  • 客户端不得向服务器的 stdin 写入任何不是有效 MCP 消息的内容。

初始化

HTTP

HTTP 传输中,服务器作为一个独立的进程运行, 可以处理多个客户端连接。

此传输使用HTTPPOSTGET 请求。

服务器可以选择使用服务器发送的事件 (SSE)以流式传输多个服务器消息。

这允许基本的 MCP 服务器,以及支持流媒体和服务器到客户端通知和请求的功能更丰富的服务器。

MCP生命周期

模型上下文协议(MCP)为客户端-服务器连接定义了严格的生命周期,以确保适当的能力协商和状态管理。

初始化

初始化

初始化

  • 客户端发送包含协议版本功能初始化请求
  • 服务器返回服务器支持的协议版本功能进行响应
  • 客户端发送初始化通知作为确认

此阶段要确认的内容如下:

  • 建立协议版本兼容性
  • 交换和谈判能力
  • 分享实施细节

请求示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize", //初始化方法
"params": {
"protocolVersion": "2024-11-05", // MCP协议版本
"capabilities": { // 支持的能力
"roots": { //支持根
"listChanged": true // 根变化的时候可以通知
},
"sampling": {} // 不支持采样
},
"clientInfo": { //客户端基本信息
"name": "ExampleClient", //客户端名称
"version": "1.0.0" //客户端版本
}
}
}

对应的服务器接收到请求以后可以返回如下内容:

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
{
"jsonrpc": "2.0",
"id": 1,
"result": { // 返回结果
"protocolVersion": "2024-11-05", //MCP协议版本
"capabilities": { // 服务器支持的能力
"logging": {}, // 日志能力
"prompts": { // 提示能力
"listChanged": true
},
"resources": { // 资源能力
"subscribe": true,
"listChanged": true
},
"tools": { // 工具能力
"listChanged": true
}
},
"serverInfo": { // 服务器基本信息
"name": "ExampleServer",
"version": "1.0.0"
},
"instructions": "Optional instructions for the client"
}
}

成功初始化以后,客户端必须发送一个初始化通知来确认初始化完成。

1
2
3
4
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}

在初始化请求中,客户端必须发送它支持的协议版本。这应该是客户端支持的最新版本。

如果服务器支持请求的协议版本,它必须以相同的版本响应。否则,服务器必须使用它支持的另一个协议版本进行响应。这应该是服务器支持的最新版本。

如果客户端不支持服务器响应中的版本,它应该 断开连接。

操作阶段

这个阶段表示已经初始化完成了,可以双方开始通信了。

在这个阶段就可以开始上面说的交流了。

这里应该注意

  • 尊重协商的协议版本
  • 仅使用已成功协商的功能

关闭

在关闭阶段,一方(通常是客户端)干净地终止协议连接。没有定义特定的关闭消息-相反,应该使用底层传输机制来发出连接终止的信号:

​对于STDIO的传输方式来说,可以进行如下步骤关闭MCP:

  • 首先,关闭子进程(服务器)的输入流
  • 等待服务器退出,或者如果服务器没有在合理的时间内退出,则发送SIGTERM信号
  • 如果服务器SIGTERM之后的合理时间内没有退出,则发送SIGKILL信号
    服务器可以通过关闭到客户端的输出流并退出来启动关机。

对于 HTTP传输方式来说,通过关闭关联的 HTTP 连接就可以了。

总结

我们主要介绍了MCP的概念,MCP的通信协议、通信方式、生命周期。MCP到底是什么东西,实现了哪些内容,使用场景以及为什么要使用MCP。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

大厂中的Code Review

周末没事的时候,出来和朋友喝酒撸串,谈论国(chui)家(chui)大(niu)事(bi)。

喝到尽兴的时候,免不了一顿八卦盛宴。男人嘛,聚在一起吹牛逼的时候,无非就是江山美人这些事。

从中华上下五千年聊到他们公司新来了美女实习生

到这里,我已经脑补出来了下文,比如“领导强迫美女实习生”或是“我和美女不得不说的故事”这种狗血剧情。

朋友A说这个美女是挺漂亮,就是技术能力不太行。改代码的时候,误删除了一行代码,导致线上出现问题了。

这时候,朋友B估计也是喝上头了,开始为美女打抱不平了。

朋友B说,人家实习生,你们也不能要求太多吧,你们公司不要让她来我们公司好了。

好小子,算盘珠子都崩我脸上了。。。

我也奇怪的问了一句,你们厂没有代码Review吗?按理说,这种问题不应该发生吧。

朋友A说,有是有,可能大家都不太认真吧,谁都没发现这个问题。。。也就稀里糊涂的上线了。。。

这个问题,还是过了一个月才发现的。。。

朋友A继续吐糟。这代码Review啊,也不是个什么好活,感觉大家都不太乐意干。主要是

  • 增加了工作量
  • 出事了还要担责

比如这次,对于这两位代码Review的同事,肯定要被追问,为啥当时没有发现。

说实话,大部分人做代码Review的时候都不做不到很认真。这是没办法的事,毕竟你又要去了解需求逻辑,还要一行行代码的去过。那没几个小时是过不完的,同时你还要干自己的工作。哪有那么多时间去认真Review呢。

所以大部分人应该也就是粗略看一下,没啥问题就给过了。

朋友A也说,他们这次痛定思痛。决定优化代码Review的流程。

优化后的代码Review

角色定义如下:

  • 提交人:代码Review的发起人。写代码的人。
  • 审查人:负责进行代码Review工作的人。需要对代码进行Review。

对于提交人来说,需要提前拉会进行CR会议,正常需求来说至少提前1天拉会。并明确以下几点:

  • 需求文档
  • 技术方案
  • 代码CR地址
  • 主要修改逻辑点
  • 自测报告
  • 上线时间

这些要求可以帮助审查人快速了解自己需要做什么,重点放在哪里,避免浪费审查人的时间。

当然了这样做确实会增加一些提交人的工作量。但是还算好的。

毕竟,需求文档、技术方案、自测报告都是现成的。也就是写一下主要修改逻辑点而已。

对于提交人来说,还需要选择合适的审查人,至少有一个审查人是了解需求的。

对于审查人来说,同样有一些要求,主要为了让审查人能够认真的对待这件事情。

  • 代码逻辑是否正确(需要了解需求)
  • 并发处理是否正确
  • 单元测试是否覆盖了核心逻辑
  • 代码命名、格式、注释。是否能让人一下子就明白这个代码是干啥的。
  • 方法抽象是否合理
  • 异常处理
  • 性能问题
  • 安全问题

至少需要检查以上几点,如果有问题及时提出来,让提交人进行解决。

FAQ

Q:如果审查人没有时间怎么办?
A:审查人需要及时回复提交人,方便提交人选择新的审查人。

Q:如果审查人意见不一致怎么办?
A:建议选择奇数个审查人,少数服从多数。

Q:审查人是否会对技术方案提出问题?
A:审查人应该聚焦于技术实现,技术方案的问题应该在技术方案评审上提出而不是代码Review上提出。

Q:紧急需求没时间代码Review了怎么办?
A:紧急需求紧急处理,快速找人Review一下即可。

总结

对于所有人来说,代码Review好像都是一个巨大的问题,很多公司甚至难以推动执行。

我觉得根本的问题在于,代码Review的收益是一个长期的、利他的收益。对于大家来讲,最直观的就是工作量的增加,如果出问题了还要担责任。

大家的公司都是怎么做代码Review的呢?可以发出来大家一起聊聊。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

大厂中的Code Review

周末没事的时候,出来和朋友喝酒撸串,谈论国(chui)家(chui)大(niu)事(bi)。

喝到尽兴的时候,免不了一顿八卦盛宴。男人嘛,聚在一起吹牛逼的时候,无非就是江山美人这些事。

从中华上下五千年聊到他们公司新来了美女实习生

到这里,我已经脑补出来了下文,比如“领导强迫美女实习生”或是“我和美女不得不说的故事”这种狗血剧情。

朋友A说这个美女是挺漂亮,就是技术能力不太行。改代码的时候,误删除了一行代码,导致线上出现问题了。

这时候,朋友B估计也是喝上头了,开始为美女打抱不平了。

朋友B说,人家实习生,你们也不能要求太多吧,你们公司不要让她来我们公司好了。

好小子,算盘珠子都崩我脸上了。。。

我也奇怪的问了一句,你们厂没有代码Review吗?按理说,这种问题不应该发生吧。

朋友A说,有是有,可能大家都不太认真吧,谁都没发现这个问题。。。也就稀里糊涂的上线了。。。

这个问题,还是过了一个月才发现的。。。

朋友A继续吐糟。这代码Review啊,也不是个什么好活,感觉大家都不太乐意干。主要是

  • 增加了工作量
  • 出事了还要担责

比如这次,对于这两位代码Review的同事,肯定要被追问,为啥当时没有发现。

说实话,大部分人做代码Review的时候都不做不到很认真。这是没办法的事,毕竟你又要去了解需求逻辑,还要一行行代码的去过。那没几个小时是过不完的,同时你还要干自己的工作。哪有那么多时间去认真Review呢。

所以大部分人应该也就是粗略看一下,没啥问题就给过了。

朋友A也说,他们这次痛定思痛。决定优化代码Review的流程。

优化后的代码Review

角色定义如下:

  • 提交人:代码Review的发起人。写代码的人。
  • 审查人:负责进行代码Review工作的人。需要对代码进行Review。

对于提交人来说,需要提前拉会进行CR会议,正常需求来说至少提前1天拉会。并明确以下几点:

  • 需求文档
  • 技术方案
  • 代码CR地址
  • 主要修改逻辑点
  • 自测报告
  • 上线时间

这些要求可以帮助审查人快速了解自己需要做什么,重点放在哪里,避免浪费审查人的时间。

当然了这样做确实会增加一些提交人的工作量。但是还算好的。

毕竟,需求文档、技术方案、自测报告都是现成的。也就是写一下主要修改逻辑点而已。

对于提交人来说,还需要选择合适的审查人,至少有一个审查人是了解需求的。

对于审查人来说,同样有一些要求,主要为了让审查人能够认真的对待这件事情。

  • 代码逻辑是否正确(需要了解需求)
  • 并发处理是否正确
  • 单元测试是否覆盖了核心逻辑
  • 代码命名、格式、注释。是否能让人一下子就明白这个代码是干啥的。
  • 方法抽象是否合理
  • 异常处理
  • 性能问题
  • 安全问题

至少需要检查以上几点,如果有问题及时提出来,让提交人进行解决。

FAQ

Q:如果审查人没有时间怎么办?
A:审查人需要及时回复提交人,方便提交人选择新的审查人。

Q:如果审查人意见不一致怎么办?
A:建议选择奇数个审查人,少数服从多数。

Q:审查人是否会对技术方案提出问题?
A:审查人应该聚焦于技术实现,技术方案的问题应该在技术方案评审上提出而不是代码Review上提出。

Q:紧急需求没时间代码Review了怎么办?
A:紧急需求紧急处理,快速找人Review一下即可。

总结

对于所有人来说,代码Review好像都是一个巨大的问题,很多公司甚至难以推动执行。

我觉得根本的问题在于,代码Review的收益是一个长期的、利他的收益。对于大家来讲,最直观的就是工作量的增加,如果出问题了还要担责任。

大家的公司都是怎么做代码Review的呢?可以发出来大家一起聊聊。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

OpenAI发布新文生图模型,免费、逼真到难以分辨

25年3月底,OpenAI进行了技术直播,对GPT-4o和Sora进行了重大更新,提供了全新文生图(Text-to-Image)模型。

除了文生图之外,该模型还支持自定义操作、连续发问、风格转换、图像PPT等超实用功能,例如,更改图片的宽高比、图像物体中的角度,用十六进制代码指定精确颜色等。

尤其是生成图像的逼真度,肉眼几乎看不出任何破绽,在精细度、细节和文本遵循方面非常出色,可以媲美甚至在某些功能超过该领域的头部平台Midjourney。

OpenAI文生图的优势

ChatGPT的文生图功能自推出以来,迅速引起了广泛关注,其主要优势包括:​

  • 精准的文本渲染能力:​ChatGPT的文生图功能能够准确理解用户的文本描述,并在生成的图像中清晰地呈现文本内容。这对于需要在图像中包含特定文字信息的场景,如制作菜单、信息图表或徽标等,具有重要意义。
  • 严格遵循用户指令:​该功能能够精确执行用户的指令,生成符合预期的图像。例如,用户可以要求生成特定风格、构图或包含特定元素的图像,ChatGPT会尽力满足这些要求。 ​
  • 深度知识调用与创意拓展:​ChatGPT不仅能够利用其广泛的知识库生成图像,还能在创意上进行拓展。例如,用户可以要求生成具有特定艺术风格或融合多种元素的图像,ChatGPT能够提供多样化的创意选项。 ​
  • 高效的图像生成速度:​在实际测试中,ChatGPT能够在约30秒内生成高质量的图像,速度远超预期。这使得用户能够迅速获取所需的图像,提升了创作效率。 ​

需要注意的是,尽管ChatGPT的文生图功能在多个方面表现出色,但在处理非拉丁语系文字时仍存在一定局限性。

文生图功能的发展历史

文生图技术其实很早就有了,只是最近随着大模型的火热以及一些文生图的应用,迎来了一波爆发。

我们也来看一下文生图的历史。​

  1. 初期探索(2014年以前)
    在深度学习兴起之前,文生图的尝试主要依赖于基本的图像处理技术,如将现有图像素材拼贴在一起,形成类似拼贴画的效果。​

  2. 深度学习引入(2014年-2018年)
    随着卷积神经网络(CNN)等深度学习模型的成功应用,研究者开始尝试使用神经网络生成图像。​2015年,多伦多大学的研究人员提出了alignDRAW模型,这是第一个现代文生图模型,能够根据文本序列生成图像。​然而,这些早期模型生成的图像质量有限,通常较为模糊。​

  3. GAN和Transformer的应用(2016年-2021年)
    2016年,研究者开始将生成对抗网络(GAN)应用于文生图任务,取得了更好的生成效果。​2019年,Transformer架构被引入文生图模型,进一步提升了生成质量。​2021年,OpenAI发布了DALL·E模型,采用Transformer架构,能够根据文本描述生成高质量的图像。​

  4. 扩散模型的兴起(2021年至今)
    2021年,以扩散模型(Diffusion Model)为基础的文生图技术取得了显著进展。​这种模型通过逐步添加噪声并学习反向过程,能够生成更高质量的图像。​例如,Stable Diffusion模型在2022年发布,提供了高质量且多样化的图像生成能力。​

openAI文生图功能演示

提示词如下:

1
帮我生成一个图片,是一座科幻风格的城市,路上有一些未来的车辆,空中还有浮空设备,路上有一些行人,有机械改造的行人

来看一下生成的图片,很高清的一张图片,也符合描述。

ai

再换成其他的一些提示词。

1
帮我生成一个图片,是一座赛博朋克风格的城市,路上有一些车辆,空中还有浮空船,路上有一些行人,有的行人装了机械假肢等。夜晚。

看一下效果。

ai

可以看到能实现的效果还是很棒的,我们在看一下图里面加上文字呢?

提示词

1
帮我生成一个五一劳动节的海报,标题是“五一劳动节快乐”,背景是学校,学校门口有一些卖东西的学生

可以看到生成的图里面对于文字的支持还是比较好的,虽然有一些瑕疵,但是比其他的文生图软件要好一些。

ai

再来试试英文的文字呢?

提示词

1
帮我生成一个五一劳动节的海报,标题是“5.1 vacation happy”,背景是学校,学校门口有一些卖东西的学生

图片效果

ai

总结

ChatGPT的文生图功能的推出,标志着内容创作进入了一个新的时代。​无论是微信公众号运营者,还是自媒体创作者,都可以借助这一功能,提升内容质量和创作效率。​随着技术的不断发展,未来的文生图功能将更加智能化、多样化,为创作者提供更多可能性。

而且,OpenAI作为大模型界的老大,它的更新速度也是很快的,说不定过不了多久,其他的文生图软件就要被甩在后面了。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

好的Prompt事半功倍

万能公式:定义角色 + 需求背景 + 实现目标 + 补充要求 + 示例

本教程为零基础教程,零基础小白也可以直接学习,有基础的可以跳到后面的原理篇学习。
基础概念和SQL已经更新完成。

接下来是应用篇,应用篇的内容大致如下图所示。

概念学习

表设计

表设计可以聊的点其实是比较多的,这个也比较看具体的业务、流量等。

比如,某一个字段是否应该放在这张表里?一张表里应该有哪些字段?如何设计字段类型?

甚至于,如何设计字段的顺序?

这里很多人不知道的一个点在于,字段的顺序也会影响性能,至于为什么,这个就偏低层一些了,下面会讲到。

想要做表设计,那你首先需要知道是什么,所以我们先来看看表到底是什么东西。

表是什么?

有的人会说,表就是Navicat上看到的一张表呗,还能是什么啊?

还有的人说,表就是一行一行数据组成的。

其实说的都对,但是这是逻辑上的表,也就是mysql给我们展现出来的表。

大家有没有想过,表的物理形式是什么,msyql如何将它转化成逻辑上的表方便我们查看呢?

接下来进行揭秘吧!

总结

通常来说,ER图是在设计阶段完成的,先有ER图再有表结构。

可如果你已经有了表结构,有没有办法生成ER图呢?

也是有方法的,比如著名的Navicat工具,就支持这么做。

此外,还有一个方法,就是使用在线工具dbdiagram,这个工具可以导入现有的SQL,会生成ER图,如下。

概念学习

这个网站是通过左边的一个叫dbml的语言来生成ER图的,也支持直接导入SQL,转化成dbml格式再生成ER图。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

MIT6.824

你在尝试构建分布式系统之前,应该先尝试单机系统,如果能满足的话不要搞分布式。

因为单机系统比分布式简单的多。

分布式的原因是需要获得更高的性能、某种并行性、大量的CPU、大量的内存、大量的磁盘。另一个原因是容忍故障,一个机器挂了还有别的机器提供服务。
还有些可能是自然的物理分布式,比如银行多个地方的转账。还有安全性,可以隔离出环境运行代码。来保证主环境的安全。

分布式系统的挑战

  • 并发性
  • 部分故障
  • 性能

本课程的目标是构建面向应用程序的基础设施:

  • 存储
  • 网络通信
  • 计算

主要目的为抽象这些能力给外部应用提供接口,隐藏内部的分布式实现。

一些已有的实现示例:

  • RPC:隐藏了通信能力
  • Thread:隐藏了多核操作系统的并发能力

构建分布式系统的高层次的目标,这也解释了为什么构建分布式系统很难:

  1. 可扩展性():系统的横向扩展能力,理想情况是加n台机器可以获得n台机器的性能。
  2. 容错性(Fault Rable):
    • 可用性:系统的高可用能力,如果有多个机器的话,当一个机器挂了,还有其他机器可以提供服务,保证系统可用。
    • 故障恢复性:当故障恢复以后,系统可以和故障恢复之前一样运行,没有数据损失等。可以通过复制的能力实现,存储多个数据副本。
  3. 一致性(Consistency):语义是当put(k,v)以后,一定会get(k)能得到v。但是对于分布式系统来说这是不一定的。
    • 强一致性
    • 弱一致性

MapReduce

最开始是Google提出的MapReduce,这篇论文可以追溯到2004年。有兴趣的可以阅读这个论文:http://nil.csail.mit.edu/6.824/2020/papers/mapreduce.pdf

当时Google面临的问题是要对数TB的数据进行计算。因为他们要从海量的数据中找出优先级最高的页面展示出来。

他们迫切的希望用数千台计算机来共同完成,来加速这个工作,而不是用一台计算机独立完成。

MapReduce希望开发者只需要编写Map函数和Reduce函数,其他的交给MapReduce框架来做。将这些函数放到无数的计算机上执行。

核心思想:将输入分成多份,产生多个输入。并对每个输入调用Map函数。

Map

Map函数将输入内容进行处理,输出一组key=>value结构。你可以把 Map 理解成分类处理的过程。

示例1:Map将从输入中统计每个英文字母出现的次数。

1
2
3
Input1 => Map => 输出:a:1, b:1, c:0
Input2 => Map => 输出:a:0, b:1, c:1
Input3 => Map => 输出:a:1, b:0, c:0

简化的Map函数如下:
Map函数接收两个参数,k是文件名称,v是文件内容

1
2
3
4
5
6
7
8
9
10
11
12
split file // 拆分文件内容
Map(k, v){
// 循环输出每个字母
for(w:words) {
//3. 输出格式同样是k=》v,k是字母,v是出现次数。
// emit函数是输出函数,由MapReduce框架提供
// 为什么输出1?因为是简单的计数,每个字母出现1次
emit(w,1);
}
}

// 实际输出结果:a:1, b:1, b:1

Reduce

Map 阶段产生的键值对,按 key 分组并聚合处理,得到最终的结果。

你可以把 Reduce 理解成对同一类的东西做总结的过程。

而Reduce函数同样接收输入,在这个示例中,我们的Reduce函数可以接收某一个字母和出现的次数做为输入,输出总的出现次数。比如:

1
2
3
Input1 (a:1, a:0, a:1) => Reduce => 输出:a:2
Input2 (b:1, b:1, b:1) => Reduce => 输出:b:3
Input3 (c:0, c:1, c:0) => Reduce => 输出:c:1

简化的Reduce函数如下:
Reduce函数同样接收文件做为输入。

1
2
3
4
5
6
// reduce函数的输入,k是用来聚合的key,在这里,这个k就是字母,可能是a,b,c。对于计算来说,用不到这个k,v是map输出的值的list。
// 比如a:1, a:0, a:1, k是a, v是[1,1]
Reduce(k, v) {
// 直接输出计数即可 因为list的长度就是计数,这是因为每个值都是1.
emit(len(v));
}

这样就计算出了所有输出中,abc三个字母出现的次数。

最妙的设计在于,按照上述的例子,我们可以部署6个机器来同时完成任务。

而且,对于Map和Reduce函数来说,优点有两个:

  1. 逻辑简单,仅仅是简单的计算逻辑。因此运行速度快
  2. 可以方便的横向扩展。

最关键的点在于

1
程序员只需关心逻辑,不用操心分布、容错、调度等复杂细节

整体执行流程

[执行流程图片]

  1. 输入数据被分片(Split)
  • 原始的大数据(比如 1TB 的日志)被切分成多个小片(通常 64MB 或 128MB 一片)。
  • 每个数据片(split)会由一个 Map Task 处理。

👉 比喻:像把一本厚书分成一页一页,由多个读者同时阅读处理。

  1. Map 阶段执行(并行执行)
  • 系统在多台机器上启动多个 Map Worker,每个负责一个 split。
  • 每个 Map Worker:
    • 读取数据片
    • 执行用户定义的 Map() 函数
    • 输出一组键值对 (key, value)
    • 把输出缓存在本地磁盘上,并根据 key 做分区(为接下来的 Reduce 做准备)

👉 比喻:每位工人处理一摞原材料,并将成果放入不同颜色的桶(按 key 分类)。

  1. 分区与 Shuffle(洗牌阶段)
  • 系统自动将所有 Map 的输出,按 key 分发给不同的 Reduce Worker
  • 这个过程称为 shuffle,是 MapReduce 的核心。
  • Reduce Worker 从各个 Map Worker 取自己负责的那一部分 key。

👉 比喻:每个桶被送到对应的收集员手里,收集员只关心自己那种颜色的桶。

  1. Reduce 阶段执行

每个 Reduce Worker:

  • 接收所有属于自己负责 key 的 (key, [value list])
  • 执行 Reduce() 函数,输出最终结果

👉 比喻:每个收集员把收到的同一类物品合并、统计或总结。

  1. 结果输出
  • Reduce 结果被写入分布式文件系统(如 GFS 或 HDFS)
  • 每个 Reduce Worker 写一个文件,形成最终的输出集合。
  1. 容错机制(Fault Tolerance)
  • MapReduce 最大的优势之一是它对机器故障有强大容错支持:
  • 任务失败了?——Master 会把任务重新分配给另一台机器。
  • 机器宕机?——系统检测心跳超时,把任务转移。

Reduce 不会从内存里读数据,而是从 Map 的本地磁盘拉,这样更安全。

👉 比喻:如果一个工人累了/走了,另一个人接手继续干,不影响整体结果。

容错机制

这是 MapReduce 的亮点之一,它自动处理各种失败情况:

🧯 Map 或 Reduce 任务失败
- Worker 崩了?任务失败?
- ✅ Master 重新调度任务,由其他空闲 Worker 重做

💀 Worker 节点宕机
- Master 检测不到心跳信号(比如 10 秒没回应)
- ✅ 所有该节点上的任务都会被视为失败,重新调度

📉 Reduce 不会因为 Map 崩了而挂掉
- 因为 Map 的中间结果会写入磁盘,且 Reduce 是拉数据

优化点 说明
数据本地性 尽量将 Map 任务调度到数据所在的机器,减少网络传输
备份任务(Backup Tasks) 在任务快结束时,为剩余最慢的任务启动副本,避免尾部拖慢整个任务(称为“straggler mitigation”)
流水线执行 Reduce Worker 可以在 Map 未完全结束时,开始拉部分数据
特性 好处
主从架构(Master/Worker) 易于调度和管理
本地磁盘缓存中间结果 提高容错性和效率
Shuffle 自动进行 程序员无需处理网络传输
容错机制完备 任意节点失败不会影响整体任务
自动调度和重试 解放程序员双手

性能

🧪 案例1:构建倒排索引(Inverted Index)
🌟 应用背景:
Google 搜索引擎需要知道每个词在哪些网页中出现。这个操作就叫“构建倒排索引”。

📦 处理规模:

  • 输入数据:约 20TB(网页内容)
  • Map 任务数:1万个
  • Reduce 任务数:2千个

⏱ 执行时间:
整个任务在几百台机器上并行,只花了几小时

✅ 意义:
传统方式实现这样的任务要花几周甚至几个月,而 MapReduce 能快速完成,还能处理节点故障。

🧪 案例2:分析网页连接图(PageRank 计算)
🌟 应用背景:
PageRank 是 Google 搜索排名的核心算法,需要处理整个互联网的网页链接关系。

📦 处理规模:

  • 输入数据:超过 1TB 的链接图
  • 运行多个 MapReduce 迭代(每一轮都读取+写入)

⏱ 执行时间:
单轮耗时在几十分钟到几小时之间,取决于迭代次数

✅ 意义:
MapReduce 适合这种需要反复运行、聚合中间结果的图算法。

可扩展性实验

论文还专门做了 实验测试 MapReduce 的可扩展性,结果非常亮眼:

实验设置:

  • 任务:排序 1TB 的数据(标准大数据计算任务)
  • 测试变量:机器数量(从几十台到几百台)
机器数量 执行时间
100 台 ~60 分钟
200 台 ~35 分钟
400 台 ~20 分钟

✅ 说明:机器数量翻倍 → 执行时间几乎减半
这叫做“近线性扩展性”,是分布式系统性能的理想状态。

容错能力实验

论文还测试了在有机器故障的情况下系统能否稳住:

实验方法:

  • 在运行中故意杀掉部分 Worker
  • 查看任务是否恢复 + 时间是否增加很多

结果:

  • 系统能成功恢复失败任务
  • 整体执行时间仅略有增加(因为失败重试带来小延迟)

✅ 意义:说明 MapReduce 的容错机制在实践中可靠,不会因为单点失败拖垮整个任务。

一些优化细节

优化策略 效果
本地性调度(Data Locality) 避免 Map 任务跨机器读取数据,减轻网络负担
Map 输出写入本地磁盘 避免 Reduce 拉取失败,提高稳定性
Backup Task(备份任务) 减少 straggler 影响,加快尾部执行
Reduce 端部分排序 避免 Reduce 端内存爆炸,提高聚合效率

小结:为什么 MapReduce 性能优秀?

方面 优势
并行计算 成千上万台机器并发执行任务
任务分片合理 拆成很多小任务,调度灵活
自动容错 节点失败不会拖垮任务
IO 优化好 避免不必要的网络流量
扩展性强 机器越多,速度越快,效率不降反升

经验

  1. 编程模型简单但表达力强
    作者观点:

    MapReduce 的接口非常简单(就两个函数:Map() 和 Reduce()),但几乎可以表达大部分并行数据处理逻辑。

实际例子:

  • 排序、去重、合并日志
  • 构建索引、计算网页权重、图处理
  • 数据挖掘任务如聚类、统计分析

🧠 体会:
你不需要了解线程、锁、通信协议这些“硬核分布式知识”,也能写出能在几千台机器上跑的大数据程序。

  1. 对“失败”高度容忍是必须的
    作者观点:

    在几百上千台机器上运行任务,机器故障是常态,不是例外。系统设计要“默认它会失败”。

做法:

  • Map/Reduce Task 自动重试
  • Master 负责监控和再调度
  • 中间结果写磁盘、持久化,方便恢复

🧠 体会:
不要去“防止失败”,要“拥抱失败”,让失败变得对用户透明,这才是工业级分布式系统。

  1. 数据本地性是性能关键
    作者观点:

    尽量把计算调度到数据所在机器,可以显著减少网络压力。

原因:

  • 在 Google 文件系统(GFS)中,数据有副本
  • Master 可以根据副本位置,把 Map 任务调到数据“身边”

🧠 体会:
在分布式系统中,“移动计算”比“移动数据”更高效

  1. Straggler 问题是真实存在的
    作者观点:

    在成百上千个任务中,总会有几个“掉队者”(straggler),它们可能因为磁盘慢、CPU 抢占等原因拖慢整个作业。

解决方案:

  • 启动 Backup Task(备份任务)
  • 哪个先完成就用哪个,放弃另一个

🧠 体会:
在大规模并发中,整体速度由“最慢的少数人”决定(这就是“长尾延迟”问题)

  1. 开发调试工具非常重要
    作者观点:

    运行成千上万个任务后,你很难靠肉眼看日志找问题,需要专门的 监控与调试工具。

Google 实践:

  • 为每个任务生成详细的 web 页面
  • 可以追踪任务状态、失败原因、数据流向
  • 所有任务的标准输出也会被收集并存档

🧠 体会:
好的工具不仅能“看见”问题,更能“预防”问题。

  1. 通用性强,支持跨部门复用
    作者观点:

    最开始 MapReduce 是为构建索引设计的,后来被应用于:

  • 日志分析
  • 机器学习数据预处理
  • 图结构计算
  • 分布式 Grep、排序、压缩
  • 多语言支持(C++、Java、Python 等)

🧠 体会:
一个简单的思想,配上良好封装与容错机制,就能成为全公司的“生产力工具”

✅ 最后,作者对读者说了什么?
他们希望告诉大家:

“MapReduce 的核心思想是抽象:程序员只需要关注如何写 Map 和 Reduce,不需要去处理分布式的复杂性。”

这种思想不仅影响了后来的 Hadoop/Spark/Flink,也启发了很多 “让人类专注业务逻辑,其余交给系统” 的工程思维。

总结:

教训/反思 含义
简单接口胜过复杂灵活 简单更易学更普及
容错不是加上去的,是设计进来的 面向失败编程
调度比你想象的重要 数据本地性和长尾问题会拖垮系统
工具让大规模系统可维护 千万别忽视监控、调试界面
通用性不是副产物,是目标 抽象设计时就考虑不同场景

和其他的对比

🧭 MapReduce 提出前,世界在干什么?
在 MapReduce 出现之前,“处理海量数据”是非常痛苦的事情,常常需要:

  • 自己手写分布式代码(多线程、RPC、容错逻辑)
  • 手动分片、调度、失败重试
  • 大量系统调优

也就是说:门槛高、出错多、效率低。

🧓 1. 前辈系统(先驱者)
MapReduce 借鉴并超越了很多已有的系统。作者提到了几个重要的前辈:

🧱 Parallel Databases(并行数据库系统)
比如:Teradata, Gamma, Volcano

  • 通过 SQL 自动并行执行、查询优化

但局限性明显:

  • 灵活性低,只适合结构化数据
  • 编程模型不够通用(不能表达复杂业务逻辑)
  • 扩展性不足(难以横向扩展到上千台机器)

MapReduce 与之不同:

  • 不需要预定义 schema
  • 可处理任意数据(文本、图像、日志)
  • 扩展性和容错机制是核心设计点

🧑‍🔧 Message Passing Systems(消息传递系统)
比如 MPI(Message Passing Interface)

  • 程序员手动控制数据传输、任务调度
  • 常用于科学计算、模拟类应用

缺点:

  • 编程复杂(需要手动处理并发、同步)
  • 容错性差(一个节点挂掉,全盘失败)
  • 不适合动态大规模分布式系统

MapReduce 优势:

  • 自动分发任务与数据
  • 自动重试失败任务
  • 容错、调度机制隐藏在框架里

🧑‍🏫 2. 编程模型的灵感来源
📚 Lisp、Functional Programming 的 Map 和 Reduce
“Map”和“Reduce”其实来自函数式编程语言 Lisp 的标准操作:

  • map(f, list):对列表中每个元素应用函数 f
  • reduce(f, list):将列表聚合为一个值(如求和)

作者把这个小而美的思想推广到了分布式系统中:

  • 把一个“大列表”切成几千块,每块并发 map
  • 最后汇总(reduce)各部分结果

创新点在于:

不是函数名的新瓶装旧酒,而是加上了调度、分布式运行、容错、持久化、分区等“工程魂”。
把“函数式思想”变成了“工业级工具”。

MapReduce 是在 Google 内部“全家桶式架构”中运行的,依赖以下底层支撑:

系统 作用
GFS(Google File System) 存储海量数据块,支持副本、高可用
Bigtable 类似 NoSQL 的结构化数据存储
Scheduler + Monitoring 提供任务调度与健康监控能力

一些其他的系统:

系统 简介 特点
Hadoop MapReduce Apache 开源实现 模仿 Google MapReduce,支持 HDFS
Dryad(微软) 更灵活的数据流图模型 支持 DAG,但复杂度也更高
Spark 更快的内存计算模型 适合交互式、大规模迭代任务
Flink 强实时数据处理 支持流+批,语义更强
Beam 通用数据处理 API 可部署到 Spark/Flink 等系统之上

对比:

角度 MapReduce 相比如何?
与并行数据库相比 更灵活、可扩展、面向通用计算
与消息传递系统相比 更易用、具备自动容错
与函数式编程相比 加入工程实现,能在真实集群跑
与后续系统相比 是“大数据系统”的思想源头,影响深远

MapReduce 的贡献不是提出了什么新理论,而是把“分布式计算”这件复杂的事做得像“写两个函数”那么简单,并真正让它在几千台机器上跑起来。

为什么这门课要使用GO语言

这门课之前使用过C++进行。

使用GO的原因如下:

  1. GO有现成的RPC包,而C++没有。
  2. GO有线程和垃圾回收的支持。而C++需要自己管理内存进行垃圾回收。
    • 因此,GO更加安全。
    • GO更加简单。
    • GO更不容易出错。
  3. GO更加简单,错误处理也更加容易,C++的错误信息很难看出来是什么错误。

这里指的线程是GO的协程。GO Routine。

线程是分布式最大的难题。

使用线程的原因:

  • IO并发:不同的程序可以处于不同的状态。比如A线程在读取磁盘信息,而B线程在执行计算。比如很多线程发送了RPC请求,等待请求响应后进行处理。
  • 多核并行性:当遇到大量的计算任务时,使用多线程同时计算会显著提高效率。两个线程会同时运行在不同的CPU上面。
  • 便捷性:可能你就是希望在后台执行某些操作,或者定时执行某些操作。比如master用来确认其他的线程的存活状态,定时每秒发送一个请求这样。

事件驱动:除了使用线程以外,还可以使用事件驱动来实现。

  • 优点:事件驱动的实现比线程更加高效,更好调试,可以用顺序的方式来编程。
  • 缺点:事件驱动只能实现并发性,而不能实现多核的并行性。无法发挥多核性能。
    • 当然了,可以通过在每个核心上启动一个线程来实现事件驱动来发挥多核性能。

线程开发的挑战:

  1. 内存共享:多个线程共享同样的内存数据,会产生数据竞争
    • 解决办法1:使用锁,但是这会导致锁开销,还要解决可能得死锁问题。有些内部的数据结构可能并不需要锁,但是你不得不支付锁的开销。这并不总是一个好主意。
    • 解决办法2:使数据不共享。
  2. 协调:当我们使用锁的时候,涉及的不同线程可能不知道其他线程的存在,他们只是想不在任何人干扰的情况下获取数据。但也有时候,你希望线程知道其他线程的存在,比如一个线程等待另一个线程完成后读取它的数据。
    • 可以使用Channels来通信
    • 使用条件变量来通信
    • 使用Wait Group来通信
  3. 死锁:两个线程互相等待对方释放锁。

GFS(谷歌文件系统)

主要是Big Storage大型分布式存储系统。为分布式系统提供底层的存储功能。

将存储的数据放到多个机器上面。

有趣的循环:

  • 性能:将数据分散到多个机器上面。通常叫做分片
  • 错误:当数据分散到多个机器上面,其中一个机器就有可能宕机,因此需要容错性
  • 容错性:可以通过存储多个副本来解决容错性的问题。
  • 副本:存储多个副本又会引入数据不一致的问题。
  • 一致性:如果需要数据强一致性。又会需要牺牲性能。

强一致性

来一个小示例:

对于一个简单的服务器来说,没有分布式的功能。

这个时候两个客户端同时发来了请求。

  • C1 写入x的值为1
  • C2 写入x的值为2

请问,这个时候服务器的x值应该是多少?

  • 答案是不确定。

但是对于后面的所有读请求来说,x的值要么都是1,要么都是2.

对于分布式系统来说,只要能达到这个效果即可。

复制版本1(不好的复制设计)

对于最简单的复制来说,就是直接启动两个服务器,S1和S2。

对于所有客户端来说,都分别请求S1和S2进行写入,但是读的时候只读S1.当S1宕机以后读取S2.

这个时候两个客户端同时发来了请求。

  • C1 请求S1 写入x的值为1
  • C2 请求S2 写入x的值为2
  • C1 请求S2 写入x的值为1
  • C2 请求S1 写入x的值为2

客户端写入

那么最终,S1存储x的值为2.S2存储x的值为1.也就产生了数据不一致。所以这是个最简单但也不好的复制设计。

GFS架构

GFS将一个文件分成多个,每个块是64MB大小。每个块都可以存在不同的服务器上面。

有一个主服务器,主服务器负责分发请求,记录了文件名称文件块的映射。

还有多个块服务器。块服务器存储了实际的块数据。

主服务器存储了以下数据:

  • 文件名称和chunk handles数组的映射。也就是每个文件分成了哪些块。这个数据是需要持久化的。
  • chunk handles和chunk servers数组的映射,也就是每个块存在哪些块服务器上。这是因为每个块都是有副本的,所以是一个服务器数组。并且客户端可以选择最近的服务器进行获取。
  • 服务器版本,需要持久化。
  • 是否是主服务器。
  • 任期结束时间。

GFS的持久化

GFS使用Log来记录持久化的信息,并通过check point来进行辅助。恢复的时候只需要从check point恢复就可以了。

其实大多数的存储系统都是这么干的。

GFS读取过程

读取过程

第四讲 Primary-Backup Replication

本讲主要内容为容错、高可用性。

如果出现网络故障、硬件故障,我们仍然可以继续提供服务,我们采用的是Replication(复制)来实现的。

本讲基于论文《The Design of a Practical System for Fault-Tolerant Virtual Machines》,实现的是一个单核CPU的VM级复制。

Replication FT(Fault-Tolerant)

复制可以处理哪些故障:

  • 单台计算机的Fail-stop
  • Fail-Stop指的是一些硬件和网络故障,这些故障会导致你的计算机停止运行,而不是输出错误。比如:拔掉你的电源插头、拔掉你的网线、你的电脑风扇坏了导致CPU过热等
  • 一些硬件故障可能会从Bug变成可以处理的Fail-Stop,比如网络传输中某些数据发送了错误,会通过校验和发现这种错误。

复制不可以处理哪些故障:

  • 软件或者硬件设计缺陷之类的东西,简称Bug。这是因为复制会将这些Bug造成的内容一起复制到其他服务器,因此所有服务器运算的结果依然是错误的。
  • 如果Primary服务器和Backup服务器之间的错误是有关联的,那么也没办法处理。比如你购买了同一种服务器无数台,那么它们可能有相同的缺陷,因此复制到其他服务器上也没办法解决,还有比如发生了地震,那么所有服务器都会损坏,复制也没有办法,当然,可以通过多机房放在不同地方来解决。

对于复制需要其他服务器的开销来说,复制究竟值不值?

这是一个经济问题而不是技术问题。如果你的应用是企业级的,有很多人在使用,出现故障的后果将导致用户流失、资金损失等等,那么复制就是值得的,如果你只是自己玩玩,或者使用用户很少,对于出现故障的后果可以接受,那么就可以不需要复制。

deterministic(确定性)是指程序是按照内部的一条条指令来顺序执行的,因此所有服务器只要顺序执行这些指令,总能产生确定的结果。
non-deterministic(不确定性)是指来自外部的指令,比如网络包的到达时间是随机的,这将产生不确定的结果。

在论文中提到了两种复制类型

  • State Transfer(状态转移):也可以叫做Passive replication
    • 故障后从主机(primary)将整个内存状态传给备机(backup)。
    • 简单直观,但代价高,尤其是应用程序状态大、更新频繁时。
    • 缺点:传输开销大,恢复延迟可能很高。
    • 因为每次都同步全量内存开销很大,因此可以进行增量同步:发送的内存状态包括了上次发送以来产生变化的内存部分。
  • Replicated State Machine(状态机复制):也可以叫做Active Replication
    • 把应用视为一个确定性状态机。
    • 主机和备机接收同样的输入请求,并保证以相同的顺序执行。
    • 如果实现完全确定性,那么即使发生故障,备机也能恢复并继续提供服务。
    • 缺点:需要保证应用的确定性执行,否则会出现状态分歧。
    • 只需要复制那些来自外部的指令就可以了。这样就把不确定性记录了下来,变成了确定性的事件。所有的服务器重放这些外部指令即可。

这个论文是基于State Machine来实现的。State Transfer的复制是复制内存,而State Machine的复制是复制外部操作。

大家更倾向于使用State Machine的原因是通常来说,外部操作的数量是要远远小于内存的。

提问:如果这里的方法出现了问题,导致Primary和Backup并不完全一样,会有什么问题?[1]

1
假设我们对GFS的Master节点做了多副本,其中的Primary对Chunk服务器1分发了一个租约。但是因为我们这里可能会出现多副本不一致,所以Backup并没有向任何人发出租约,它甚至都不知道任何人请求了租约,现在Primary认为Chunk服务器1对于某些Chunk有租约,而Backup不这么认为。当Primary挂了,Backup接手,Chunk服务器1会认为它对某些Chunk有租约,而当前的Primary(也就是之前的Backup)却不这么认为。当前的Primary会将租约分发给其他的Chunk服务器。现在我们就有两个Chunk服务器有着相同的租约。这只是一个非常现实的例子,基于不同的副本不一致,你可以构造出任何坏的场景和任何服务器运算出错误结果的情形。我之后会介绍VMware的方案是如何避免这一点的。[1]

提问:随机操作在复制状态机会怎么处理?[1]

1
我待会会再说这个问题,但是这是个好问题。只有当没有外部的事件时,Primary和Backup都执行相同的指令,得到相同的结果,复制状态机才有意义。对于ADD这样的指令来说,这是正确的。如果寄存器和内存都是相同的,那么两个副本执行一条ADD指令,这条指令有相同的输入,也必然会有相同的输出。但是,如你指出的一样,有一些指令,或许是获取当前的时间,因为执行时间的略微不同,会产生不同的结果。又或者是获取当前CPU的唯一ID和序列号,也会产生不同的结果。对于这一类问题的统一答案是,Primary会执行这些指令,并将结果发送给Backup。Backup不会执行这些指令,而是在应该执行指令的地方,等着Primary告诉它,正确的答案是什么,并将监听到的答案返回给软件。[1]

本次论文是基于单个CPU的,这是因为多核CPU实现起来很复杂,来自两个CPU的指令的交错是不确定的。而且对于性能也是有影响的,根据论文中的实验结果表明,单核CPU的性能下降大约5-10%。多核的复杂度更高,因此性能会下降更多,可能>=30%

多核CPU的复制通常是基于State Transfer的,因为这个实现在多核和并行性方面更加稳健,毕竟只需要同步内存即可。

State Machine

如果想实现一个State Machine的复制方案,有很多问题需要回答:

  • 在什么级别上复制状态机?
    • 本论文中提到的复制状态机是独一无二的,因为它运行在很低的级别上,运行在机器级别上。
    • 这意味着主备服务器是完全一样的,哪怕是在机器级别上也是完全一样的。
    • 大部分的复制方案都是应用程序级别的复制,比如上面的GFS。
    • 优点是你的机器上可以运行任何程序,都可以使用这个复制,因为机器级别是完全一样的
    • 缺点是实现起来比应用复制更加困难。
  • 什么是状态?
    • 这里的状态包括(CPU、内存、IO设备)。
  • 主备服务器之间的同步程度如何?
    • 主服务器一定比备服务器先执行
    • 如果主服务器故障了,备服务器可能无法赶上主服务器的进度
    • 也可以使用强一致性,但是会影响速度,这就需要取舍
  • 如果主服务器挂了,需要一种切换方案
    • 并且客户端也需要知道,现在应该和新的主服务器通信,而不是旧的主服务器
    • 理想情况下,主备服务器的切换应该是对于客户端来说无感知的,但是这个很难实现
  • 如果备服务器挂了,那么需要一个新的备服务器
    • State Machine只是一种更廉价的同步方案,但是如果我们需要一个新的备服务器,那么它需要追上现在的主服务器,我们只能使用State Transfer来实现。

上面的这些问题都是需要解决的。

VMware FT

VMware是一家虚拟机公司,主要业务是售卖虚拟机。虚拟机的意思是,你买一台计算机,通常只能在硬件上启动一个操作系统。但是如果在硬件上运行一个虚拟机监控器(VMM,Virtual Machine Monitor)或者Hypervisor,Hypervisor会在同一个硬件上模拟出多个虚拟的计算机。所以通过VMM,可以在一个硬件上启动一到多个Linux虚机,一到多个Windows虚机。[1]

VMware FT至少需要两个物理机,一个作为主服务器,一个作为备服务器,使用两个虚拟机在同一个物理机上的话,这毫无意义,因为物理机一挂就全完了。

tu1

假设两个服务器都连接到网络上,并且网络上有一些客户端,根据论文的描述,两个服务器使用的是一个共享的文件存储系统,例如GFS这样的系统。

tu2

系统由三类主要实体组成:主机侧 VM(Primary)、备机侧 VM(Backup),以及共享存储(Shared Disk)和日志/控制通道。核心在于 FT hypervisor(在主备两侧分别运行)——它拦截 VM 与虚拟设备/CPU 的交互,捕获所有非确定性事件(比如外部中断、时间读取、网络/磁盘 I/O 完成、性能计数器触发等),把这些事件封装为日志项并通过日志通道发送给备机。备机 FT hypervisor 在收到日志后,以相同的指令计数或事件点“中断”备机并注入相同事件,从而使备机按相同顺序“重放”主机执行。为避免因为 DMA 或 I/O 竞争而导致主备读取不同数据,系统采用 bounce buffer:I/O 先拷贝到 hypervisor 专用缓冲区,完成后再同时(以相同虚拟时间点)复制到主/备 guest 内存。输出规则(Output Rule)要求在主发出外部输出前必须等待备机 ack 到位,以保证主失败后备机可以继续且不会丢失外部可见输出。共享磁盘的 test-and-set 提供了仲裁器,防止网络分区下的 split-brain。系统还包含管理层以在故障后自动在集群其他主机上重新创建备。

整体架构图如下

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
flowchart TD
subgraph PrimaryHost
PVM[Primary VM]
PFT[FT Hypervisor Primary side]
PLogChannel[Logging Channel Sender]
end

subgraph BackupHost
BVM[Backup VM lagging]
BFT[FT Hypervisor Backup side]
BAckChannel[Logging Channel Ack]
end

SharedDisk[Shared Storage Network Disk w/ test-and-set]
Network[External Network / Clients]

PVM -->|I/O, interrupts| PFT
PFT -->|log non-deterministic events| PLogChannel
PLogChannel ---|send logs| BAckChannel
BAckChannel -->|ack| PFT
BFT -->|replay events| BVM
PVM -->|network packets| Network
PFT -->|Output Rule: wait for ack before output| Network
PFT --- SharedDisk
BFT --- SharedDisk

各组件功能与职责(输入/输出、状态机、生命周期)

  • PFT / BFT(FT Hypervisor)
    • 输入:来自 guest 的指令流、设备事件、I/O 完成、时间读取请求;来自日志通道的日志项(备侧)。
    • 输出:日志项(主侧发送)、ack(备侧发送)、控制动作(在 guest 上注入中断或事件)、外部输出放行控制(Output Rule)。
    • 状态机:{Running, LoggingPending, WaitingForAck, PausedForReplay, GoLive}。
    • 生命周期:初始化 -> 维持日志通道与备机同步 -> 在主失败后触发 GoLive -> 完成恢复与通知管理层 -> 可能被替换/重建备机。
  • Logging Channel
    • 输入:非确定性事件序列(序号、类型、数据)。
    • 输出:日志发送(主->备)与 ack(备->主)。
    • 属性:可靠性优先(需保证日志到达或重传);低延迟以限制主备延迟。
  • Shared Disk(含 test-and-set)
    • 输入:test-and-set 请求、磁盘 I/O。
    • 输出:test-and-set 返回值、磁盘读写完成信号。
    • 职责:仲裁在网络分区时哪个节点可以 go-live(避免双活)。

流程图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequenceDiagram
participant Client
participant PrimaryFT
participant PrimaryVM
participant LogChannel
participant BackupFT
participant BackupVM
participant SharedDisk

Client->>PrimaryVM: request
PrimaryVM->>PrimaryFT: exec (causes non-determ event)
PrimaryFT->>LogChannel: send(logEntry)
LogChannel->>BackupFT: deliver(logEntry)
BackupFT->>BackupVM: inject(event) -- replay
BackupFT-->>LogChannel: ack(logEntry)
LogChannel-->>PrimaryFT: ack
PrimaryFT->>PrimaryVM: allow output (Output Rule satisfied)
PrimaryVM->>Client: response

关键节点说明:

  • 当主 VM 产生非确定性事件(如中断或 DMA 完成)时,主 FT 先把事件封装到日志发送到备;在收到备的 ack 之前,主 FT 不允许与该事件相关的外部可见输出生效(Output Rule)。
  • 备收到日志后必须把备机停在与主相同的指令计数点注入相同事件,完成后发回 ack。两侧以 instruction-count 或 CPU performance counter 来对齐“中断点”。

数据流与控制流(完整请求的处理路径)

  1. 请求到达主 VM → VM 在 guest OS 内执行产生设备/内核调用 → hypervisor 拦截非确定性点(time, IO complete)并记录日志项。
  2. 日志发送 → log channel 将事件推送至备。
  3. 备侧重放 → 备 FT 在合适的 instruction-count 中断点插入事件并重放到备 VM。
  4. ack 返回 → 备确认后返回 ack。
  5. Output Rule 检查通过 → 主 FT 允许主 VM 的输出(网络包或磁盘提交)被真正发送/提交。
  6. 若主失败 → 备已经包含所有产生外部输出的前置事件,执行 go-live,继续对外提供服务;必要时重发/重新提交未完成的 I/O。

Logging Channel

主服务器到备服务器之间的数据流同步的通道被称为Log Channel。同步的数据事件被称为Log Event/Log Entry

当主服务器遇到故障停止的时候,就不会再往Log Channel中发送消息了,备服务器发现没有消息了,就知道主服务器挂了。主服务有一个定时器中断,每秒会往Log Channel中发送消息。因此,当1s以后备服务器没有收到消息,就知道主服务器挂了。

当备服务器发现主服务器挂了,备服务器就会变成主服务器。备服务器不再接受主服务器的Log Entry。可以自由行动,并且通知客户端请求这个新的主服务器。

提问:Backup怎么让其他客户端向自己发送请求?[1]

1
魔法。。。取决于是哪种网络技术。从论文中看,一种可能是,所有这些都运行在以太网上。每个以太网的物理计算机,或者说网卡有一个48bit的唯一ID(MAC地址)。下面这些都是我(Robert教授)编的。每个虚拟机也有一个唯一的MAC地址,当Backup虚机接手时,它会宣称它有Primary的MAC地址,并向外通告说,我是那个MAC地址的主人。这样,以太网上的其他人就会向它发送网络数据包。不过这只是我(Robert教授)的解读。学生提问:随机数生成器这种操作怎么在Primary和Backup做同步?Robert教授:VMware FT的设计者认为他们找到了所有类似的操作,对于每一个操作,Primary执行随机数生成,或者某个时间点生成的中断(依赖于执行时间点的中断)。而Backup虚机不会执行这些操作,Backup的VMM会探测这些指令,拦截并且不执行它们。VMM会让Backup虚机等待来自Log Channel的有关这些指令的指示,比如随机数生成器这样的指令,之后VMM会将Primary生成的随机数发送给Backup。论文有暗示说他们让Intel向处理器加了一些特性来支持这里的操作,但是论文没有具体说是什么特性。[1]

提问:随机数生成器这种操作怎么在Primary和Backup做同步?[1]

1
VMware FT的设计者认为他们找到了所有类似的操作,对于每一个操作,Primary执行随机数生成,或者某个时间点生成的中断(依赖于执行时间点的中断)。而Backup虚机不会执行这些操作,Backup的VMM会探测这些指令,拦截并且不执行它们。VMM会让Backup虚机等待来自Log Channel的有关这些指令的指示,比如随机数生成器这样的指令,之后VMM会将Primary生成的随机数发送给Backup。论文有暗示说他们让Intel向处理器加了一些特性来支持这里的操作,但是论文没有具体说是什么特性。[1]

Non-Deterministic Event

non-deterministic(不确定性)是指来自外部的指令,比如网络包的到达时间是随机的,这将产生不确定的结果。

  • 客户端的请求就是一个不确定性事件,因为客户端请求的到达时间是随机的,不可预测的。

从客户端输入到达,数据包里的数据将会被拿出来,触发IO中断,通过NIC DMA将数据包内容写入内存。然后引发操作系统感知到中断。

所以,真正的问题是中断发生的时间中断恰好发生在哪个指令上。最好在主服务器和备服务器上是相同的。

  • 一些指令也是不确定性事件,比如随机数生成、获取当前时间、获取计算机唯一ID这些。
  • 还有就是多核CPU并行程序(所以本论文不考虑)

多核CPU交错执行指令,可能在主服务器上是CPU1获取到lock,而在备服务器上执行的时候确是CPU2获取到lock来执行操作,那么结果就有可能不同了。这是不可预测的结果。

Log Entry

对于一个Log Entry来说,可能包含以下内容:

  • 事件发生时候的指令序号。因为如果要同步中断或者客户端输入数据,最好是Primary和Backup在相同的指令位置看到数据,所以我们需要知道指令序号。这里的指令号是自机器启动以来指令的相对序号,而不是指令在内存中的地址。比如说,我们正在执行第40亿零79条指令。所以日志条目需要有指令序号。对于中断和输入来说,指令序号就是指令或者中断在Primary中执行的位置。对于怪异的指令(Weird instructions),比如说获取当前的时间来说,这个序号就是获取时间这条指令执行的序号。这样,Backup虚机就知道在哪个指令位置让相应的事件发生。[1]
  • Log Entry的类型,可能是普通指令,或者怪异指令,如获取时间。
  • 网络包中的数据,如果是怪异指令,那么将是怪异指令的执行结果,这样备服务器就会直接使用这个结果。

主服务器和备服务器两个虚拟机内部的guest操作系统需要在模拟的硬件里有一个定时器,注意,是模拟的虚拟机里面的硬件。每执行1000条指令,就会触发一次中断。这样操作系统才可以通过对这些中断进行计数来跟踪时间。因此,这里的定时器必须在主服务器和备服务器虚拟机的完全相同位置产生中断,否则这两个虚拟机不会以相同的顺序执行指令,进而可能会产生分歧。

所以,在运行了主服务器的物理服务器上,有一个定时器,这个定时器会计时,生成定时器中断并发送给VMM。在适当的时候,VMM会停止主服务器的指令执行,并记下当前的指令序号,然后在指令序号的位置插入伪造的模拟定时器中断,并恢复主服务器的运行。

之后,VMM将指令序号和定时器中断再发送给备服务器。虽然备服务器的VMM也可以从自己的物理定时器接收中断,但是它并没有将这些物理定时器中断传递给备服务器的guest操作系统,而是直接忽略它们。这是因为备服务器并不需要记录并发送这些,只有主服务器才需要感知并发送。

当来自于主服务器的Log Entry到达时,备服务器的VMM配合特殊的CPU特性支持,会使得物理服务器在相同的指令序号处产生一个定时器中断,之后VMM获取到这个中断,并伪造一个假的定时器中断,并将其送入备服务器的guest操作系统,并且这个定时器中断会出现在与主服务器相同的指令序号位置。

特殊的CPU特性指的是,VMM会告诉CPU,执行1000条指令以后就中断一次,方便VMM将伪造的中断注入,这样主备服务器就会在相同的指令位置触发相同的中断。在当时看来特殊的CPU特性,现在已经很普遍了。现在这个功能还有很多其他用途,比如说做CPU时间性能分析,可以让处理器每1000条指令中断一次,这里用的是相同的硬件让微处理器每1000条指令产生一个中断。所以现在,这是CPU中非常常见的一个小工具。

问:如果备服务器领先了主服务器会咋样?

1
2
3
我们不能允许这样的情况发生,因为这样会出现问题。
VMware FT的做法是在备服务器中有一个`缓冲区`。当Log Entry到达以后,会进入这个缓冲区,如果缓冲区不为空,那么它可以根据Log的信息知道主服务器对应的指令序号,并且会强制备服务器最多执行指令到这个位置。
所以,只有在主服务器将Log Entry发送到缓冲区以后,备服务器才会开始执行,这样,备服务器就会落后于主服务器。

网络数据包送达时,有一个细节会比较复杂。当网络数据包到达网卡时,如果我们没有运行虚拟机,网卡会将网络数据包通过DMA的方式送到计算机的关联内存中。现在我们有了虚拟机,并且这个网络数据包是发送给虚拟机的,在虚拟机内的操作系统可能会监听DMA并将数据拷贝到虚拟机的内存中。因为VMware的虚拟机设计成可以支持任何操作系统,我们并不知道网络数据包到达时操作系统会执行什么样的操作,有的操作系统或许会真的监听网络数据包拷贝到内存的操作。

我们不能允许这种情况发生。如果我们允许网卡直接将网络数据包DMA到Primary虚机中,我们就失去了对于Primary虚机的时序控制,因为我们也不知道什么时候Primary会收到网络数据包。所以,实际中,物理服务器的网卡会将网络数据包拷贝给VMM的内存,之后,网卡中断会送给VMM,并说,一个网络数据包送达了。这时,VMM会暂停Primary虚机,记住当前的指令序号,将整个网络数据包拷贝给Primary虚机的内存,之后模拟一个网卡中断发送给Primary虚机。同时,将网络数据包和指令序号发送给Backup。Backup虚机的VMM也会在对应的指令序号暂停Backup虚机,将网络数据包拷贝给Backup虚机,之后在相同的指令序号位置模拟一个网卡中断发送给Backup虚机。这就是论文中介绍的Bounce Buffer机制.

问:怪异的指令(Weird instructions)会有多少呢?

1
怪异指令非常少。只有可能在Primary和Backup中产生不同结果的指令,才会被封装成怪异指令,比如获取当前时间,或者获取当前处理器序号,或者获取已经执行的的指令数,或者向硬件请求一个随机数用来加密,这种指令相对来说都很少见。大部分指令都是类似于ADD这样的指令,它们会在Primary和Backup中得到相同的结果。每个网络数据包未做修改直接被打包转发,然后被两边虚拟机的TCP/IP协议栈解析也会得到相同的结果。所以我预期99.99%的Log Channel中的数据都会是网络数据包,只有一小部分是怪异指令。所以对于一个服务于客户端的服务来说,我们可以通过客户端流量判断Log Channel的流量大概是什么样子,因为它基本上就是客户端发送的网络数据包的拷贝。

output

假设主服务器和备服务器上现在的数据库里面的数据都是10.

[tu]

  1. 客户端请求主服务器,要求数据+1。
  2. 主服务器接收到请求,将10更新为11
  3. 主服务器将Log Entry发送到备服务器
  4. 主服务器将结果返回给客户端
  5. 备服务器将10更新为11

如果出现故障了怎么办?这门课程中,你需要始终考虑,故障的最坏场景是什么,故障会导致什么结果?

  • 如果主服务器回复客户端以后崩溃了
  • 更糟糕的是主备服务器网络挂掉了,备服务器没有收到主服务器的这个Log Entry

如果这个时候客户端再次发送+1请求到主服务器(这个时候的主服务器是原来的备服务器,因为原来的主服务器崩溃了)

  1. 客户端请求主服务器,要求数据+1。
  2. 主服务器接收到请求,将10更新为11。(因为备服务器没有收到Log Entry,所以备服务器数据还是10)
  3. 主服务器将结果返回给客户端

这个时候就有问题了,因为客户端两次+1,期望结果是12,但是实际结果确是11.

因为VMware FT的优势就是在不修改软件,甚至软件都不需要知道复制的存在的前提下,就能支持容错,所以我们也不能修改客户端让它知道因为容错导致的副本切换触发了一些奇怪的事情。在VMware FT场景里,我们没有修改客户端这个选项,因为整个系统只有在不修改服务软件的前提下才有意义。所以,前面的例子是个大问题,我们不能让它实际发生。[1]

论文中的解决思路是Output Rule(输出规则)

Output Rule

我们来看一下什么是输出规则。

假设主服务器和备服务器上现在的数据库里面的数据都是10.我们改变一下执行顺序。

  1. 客户端请求主服务器,要求数据+1。
  2. 主服务器接收到请求,将10更新为11
  3. 主服务器将Log Entry发送到备服务器,备服务器将Log Entry放倒缓冲区,就可以返回一个ACK给主服务器
  4. 主服务器将结果返回给客户端(这里需要同步等待,VMM会等待,直到备服务器的VMM发送一个ACK,表示备服务器已经收到了Log Entry。主服务器的VMM才会将结果返回给客户端)
  5. 备服务器将10更新为11

这样的话就实现了主备服务器的强一致性,避免了上面的问题。

  • 如果主服务器回复客户端以后崩溃了:那么备服务器接管以后,数据也会变成11,再次+1变成12,符合预期。
  • 如果网络崩溃了,那么因为备服务器没有接收到Log Entry,也没有返回ACK,所以主服务器不会返回给客户端结果。客户端再次发送请求+1,结果为11,符合预期。

所以,Primary会等到Backup已经有了最新的数据,才会将回复返回给客户端。这几乎是所有的复制方案中对于性能产生伤害的地方。这里的同步等待使得Primary不能超前Backup太多,因为如果Primary超前了并且又故障了,对应的就是Backup的状态落后于客户端的状态。[1]

这个解决方案,会随着网络的消耗增涨而增长。根据论文的描述,对于性能的影响大概在5-10%。

所以,条件允许的话,人们更喜欢使用在更高层级做复制的系统。这就需要一些应用层的复制机制。

重复输出

假设备服务器的缓冲区积累了大量的Log,这个时候主服务器崩溃了,那么备服务器就需要消耗这些Log以后达到和主服务器相同的状态然后变成主服务器。

这个时候,其中有的Log是客户端的请求,这个时候备服务器会将结果,比如11返回给客户端。但是对于客户端来说,这是一个重复的结果。

巧妙的是,备服务器会使用和主服务器相同的TCP序号,而对于客户端来说,在TCP层面会发现这是一个重复消息,并丢弃。客户端的应用层对此是不感知的。

重复输出对于复制来说,基本是不可避免的,所以我们一定要实现幂等

Split Brain(脑裂)

Split Brain是指,主服务器并不是挂掉了,可能只是因为某些网络原因导致备服务器错误的认为主服务器挂掉了。

如果这个时候备服务器变成主服务器,就会产生两个主服务器。

论文的解决方案是,有一个test and set服务器,这是一个外部的服务器,并且应该是一个高可用的服务器,不然会产生单点故障。

test and set服务器假设内存中有一个标志,当你向它发送一个Test-and-Set请求,它会设置标志位,并且返回旧的值。

这样的话,当主服务器实际上没有挂掉,备服务器请求test and set服务器的时候,会被告知,已经有主服务器了,你不能成为主服务器。

第五讲 GO Threads and Raft

这节课讲的内容如下:

  • Go memory model(go 内存模型)
  • Concurrency primitives(并发原语)
  • Concurrency patterns(并发模式)
  • Debugging

本节课基于【go memery model】官方文档。

如果你需要阅读这个官方文档,那说明你太聪明了。。因为我们只应该关心程序的正确性,而不是过多的去关注 Happens Before

closures(闭包)

闭包可以用在go routines,闭包可以使用外部的变量。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "sync"

func main() {
var a string
var wg sync.WaitGroup
wg.Add(1)
go func() {
a = "hello world"
wg.Done()
}()
wg.Wait()
println(a)
}

通过go来开启了一个go routines,并使用了一个闭包,闭包里面使用了外部定义的变量a和wg。

要注意的一点是,如果要在循环中使用闭包,千万不要直接使用循环中的变量,因为循环会修改这个变量,被外部修改的变量,在闭包里面也会被修改,因为底层是同一个变量。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "sync"

func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
sendRPC(i)
wg.Done()
}()
}
wg.Wait()
}

func sendRPC(i int) {
println(i)
}

这个程序可能会打印出55555而不是我们期望的01234。这是因为打印的时候,可能i已经变成了5.

正确的写法如下,应该把变量传递给闭包,这样的话,就是传值而不是传址。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "sync"

func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
// 传递变量i,接收变量x
go func(x int) {
sendRPC(x)
wg.Done()
}(i)
}
wg.Wait()
}

func sendRPC(i int) {
println(i)
}

并发原语

并发原语包括以下几种方法:

  • Mutex(锁)
  • Condition variables(条件变量)
  • Channel

Mutexes

下面的示例是一个计数程序,通过多个go routines进行计数,如果没有加上并发原语,那么就会出现错误。这是因为出现了共享数据counter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "time"

func main() {
counter := 0
for i := 0; i < 1000; i++ {
go func() {
counter = counter + 1
}()
}

time.Sleep(1 * time.Second)
println(counter)
}

正确的做法是加上并发原语,我们通过这个并发原语来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "sync"
import "time"

func main() {
counter := 0
var mu sync.Mutex
for i := 0; i < 1000; i++ {
go func() {
mu.Lock()
defer mu.Unlock()
counter = counter + 1
}()
}

time.Sleep(1 * time.Second)
mu.Lock()
println(counter)
mu.Unlock()
}

加上锁以后,可以看到结果正常了。

下面是一个银行转账的示例,这个示例表示我们对于锁的应用原则应该是保护不变量,而不是简单的保护共享数据

下面的代码是使用锁来保护共享数据alice和bob,结果是发现账户总额有时候不对。这是因为中间锁释放的时候,我们预期的不变量total产生了变化。

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
package main

import "sync"
import "time"
import "fmt"

func main() {
alice := 10000
bob := 10000
var mu sync.Mutex

total := alice + bob

go func() {
for i := 0; i < 1000; i++ {
mu.Lock()
alice -= 1
mu.Unlock()
mu.Lock()
bob += 1
mu.Unlock()
}
}()
go func() {
for i := 0; i < 1000; i++ {
mu.Lock()
bob -= 1
mu.Unlock()
mu.Lock()
alice += 1
mu.Unlock()
}
}()

start := time.Now()
for time.Since(start) < 1*time.Second {
mu.Lock()
if alice+bob != total {
fmt.Printf("observed violation, alice = %v, bob = %v, sum = %v\n", alice, bob, alice+bob)
}
mu.Unlock()
}
}

正确的方式如下:我们应该保护不变量,所以应该等待转账完成以后再释放锁。

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
package main

import "sync"
import "time"
import "fmt"

func main() {
alice := 10000
bob := 10000
var mu sync.Mutex

total := alice + bob

go func() {
for i := 0; i < 1000; i++ {
mu.Lock()
alice -= 1
bob += 1
mu.Unlock()
}
}()
go func() {
for i := 0; i < 1000; i++ {
mu.Lock()
bob -= 1
alice += 1
mu.Unlock()
}
}()

start := time.Now()
for time.Since(start) < 1*time.Second {
mu.Lock()
if alice+bob != total {
fmt.Printf("observed violation, alice = %v, bob = %v, sum = %v\n", alice, bob, alice+bob)
}
mu.Unlock()
}
}

Condition variables

条件变量是另外一个可以使用的并发原语.

下面的一个例子表示在Raft的选举过程中,我们需要获取其他节点的投票,使用count记录获取到的投票数量,finished表示投票的节点数量。

我们使用多个go routines同时请求其他节点,获取投票信息。

按照锁的使用来说,我们需要用锁来保护不变量,如下所示:

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
package main

import "sync"
import "time"
import "math/rand"

func main() {
rand.Seed(time.Now().UnixNano())

count := 0
finished := 0
var mu sync.Mutex

for i := 0; i < 10; i++ {
go func() {
vote := requestVote()
mu.Lock()
defer mu.Unlock()
if vote {
count++
}
finished++
}()
}

for {
mu.Lock()

if count >= 5 || finished == 10 {
break
}
mu.Unlock()
}
if count >= 5 {
println("received 5+ votes!")
} else {
println("lost")
}
mu.Unlock()
}

func requestVote() bool {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
return rand.Int() % 2 == 0
}

上面的代码是可以运行的,但是一个不断的for循环几乎占满全部的CPU,这是一个极大的CPU浪费,并且如果总是for循环获取到锁,那么上面的投票代码就没有办法执行了。

对于这种情况,我们就可以使用条件变量。定义一个条件变量cond。在for循环中使用Wait等待条件变量被唤醒。当投票结果出来以后,使用Broadcast来唤醒所有的条件变量。这样就避免了无休止的for循环。

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
package main

import "sync"
import "time"
import "math/rand"

func main() {
rand.Seed(time.Now().UnixNano())

count := 0
finished := 0
var mu sync.Mutex
cond := sync.NewCond(&mu)

for i := 0; i < 10; i++ {
go func() {
vote := requestVote()
mu.Lock()
defer mu.Unlock()
if vote {
count++
}
finished++
cond.Broadcast()
}()
}

mu.Lock()
for count < 5 && finished != 10 {
cond.Wait()
}
if count >= 5 {
println("received 5+ votes!")
} else {
println("lost")
}
mu.Unlock()
}

func requestVote() bool {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
return rand.Int() % 2 == 0
}

Channel

通道也是一种并发原语

通道类型:

  • 无缓冲通道
  • 有缓冲通道

推荐使用无缓冲通道。

通道使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "time"
import "math/rand"

func main() {
c := make(chan int)

for i := 0; i < 4; i++ {
go doWork(c)
}

for {
v := <-c
println(v)
}
}

func doWork(c chan int) {
for {
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
c <- rand.Int()
}
}

通道是一个同步的方法,是一个同步原语。是会阻塞的,因此需要在不同的go routines中使用,如果在同一个线程中使用,就会出现死锁。

代码永远会阻塞在c <- true这一行,因为没有一个接收的通道等待。

1
2
3
4
5
6
7
package main

func main() {
c := make(chan bool)
c <- true
<-c
}

当然了,有缓冲通道可以避免上面的问题,但是并不推荐使用,因为这样可能会导致无法发现上面的问题。因为在不同的go routines中也有可能因为某些原因导致死锁。对于上面这种,go可以检测出来,但是如果在多个go routines中就不会被检测出来了。

有缓冲通道代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "time"
import "fmt"

func main() {
c := make(chan bool, 1)
go func() {
time.Sleep(1 * time.Second)
<-c
}()
start := time.Now()
c <- true
fmt.Printf("send took %v\n", time.Since(start))

start = time.Now()
c <- true
fmt.Printf("send took %v\n", time.Since(start))
}

死锁

这里讨论了当Raft选举时候的一些死锁问题。

比如:不要在RPC请求选举的时候加锁,因为请求选举的时候加锁,但是同时你可能还需要处理选举。

  • 节点0开始选举
  • 节点2投票给节点0
  • 节点1也开始选举
  • 节点2投票给节点1
  • 节点0等待节点1的投票结果
  • 节点1等待节点0的投票结果

这里就出现了死锁,因为节点0在等待投票结果的时候不会处理投票,节点1也是,因此互相等待。

这里给出的解决方案是不要在RPC请求加锁,而是在更高层级加锁保护数据。

这引出了别的问题,比如同时节点0和节点1被选举成为了领导者,这是错误的,所以需要增加一些判断条件。

Debug

可以使用下面的命令来检查数据竞争。

1
go test -race -run

Go 的默认 SIGQUIT 处理器为所有 go routine 打印堆栈跟踪(然后退出)

  • Ctrl+\ 将向当前进程发送 SIGQUIT

第六讲 Fault Tolerace: Raft(1)

回顾一下之前讲过的几个具备容错性的系统

  • MapReduce复制了计算,但是复制这个动作,只有一个Master主节点,容易出现单点故障
  • GFS以主备方式复制数据,同样依赖单个主节点,容易出现单点故障
  • VMware FT出现故障的时候,备服务器需要请求test and set服务器来进行升级到主服务器,但是test and set服务器确是一个单点故障

这三个系统都需要一个单节点来决定谁是主节点,这样有好处,就是一言堂,我说啥就是啥。

但是坏处就是单点故障了,上面的三个系统具备了容错性,但是容错依赖这个单点。这个单点会出现单点故障。

脑裂

但是为什么test and set需要是一个单点,而不是一个服务器集群呢?

这是为了避免脑裂问题。假设test and set服务器是一个集群。

我们来看一个示例:

假设有两个VMware FT的服务器。称作C1和C2。同样有两个test and set服务器,称作S1和S2。假设这个时候出现了网络故障,导致S1和S2无法通信。同样的C1也无法和S2通信,只能和S1通信了,C2只能和S2通信了。

那么后果就是,当C1想成为主服务器的时候,S1允许了,因为S1这里的标志位是0.当C2想成为主服务器的时候,S2允许了,因为S2这里的标志位也是0.那么就出现了C1和C2同时成为主服务器的情况,这就坏了。所以才需要使用单个test and set服务器。

如图所示
[tu4]

总结一下

要么会有单点故障,要么会有脑裂问题

Raft

Raft解决了脑裂问题和单点故障问题。

采用的是一种Majority Vote(多数投票)方式,这种方式要求服务器数量必须是奇数个,只有这样才能保证有多数,也就是最低需要三个服务器。只要其中的2个服务器投票出一个leader就可以了,所有的操作由leader来决定。

另一个比较有趣的点在于,假设现在有服务器ABC三个,A是leader,因为A获取了AB的投票。当A失去连接的时候,C成为了leader,因为C获取了BC的投票。

对于A的投票和C的投票来说,其中一定有B服务器的一票,用数学概念来说,就是上一个leader的投票者是一个集合a,当前leader的投票者是一个集合b,集合b一定和集合a相交。因此当前leaderC一定知道上一个leaderA的任期是什么时候。因为B作为投票者是知道A的任期的。

Raft实际上是一个偏低层的library(库)。它的上面是应用层序代码,Raft只复制操作日志,并保持日志的一致性。

如图所示。上面是一个KV应用程序,下方代表Raft。并且因为Raft最少需要三个服务器,因此应用程序需要有三个服务器。

[tu5]

除了Raft以外,还有早期的PaxosViewStamped Replication也可以做到这个,但是Paxos难以理解并且难以实际的去实现。Raft解决了Paxos难以理解和实现的问题,当然了,Raft从设计上来讲也更接近ViewStamped Replication。ViewStamped Replication是由MIT发明的。

Raft执行流程

假设现在有一些客户端,客户端会将请求发送给Leader服务器来处理,对于客户端来说,并不感知服务器的容错能力,不需要知道有副本的存在,多个服务器在客户端眼里就像单个服务器一样。

传统单点服务请求流程

  1. 客户端发送PUT(a,1)请求给KV服务器
  2. KV服务器处理请求,保存a:1
  3. KV服务器返回处理成功
  4. 客户端发送GET(a)请求
  5. KV服务器返回1给客户端

对于加上Raft进行容错的应用来说,请求流程就变了。

  1. 客户端发送PUT(a,1)请求给KV服务器
  2. KV服务器将请求给Raft层。
  3. Raft层将操作放入复制日志中,和其他Raft服务器同步该日志,当大多数Raft服务器同步成功以后,Raft层返回KV服务器成功
  4. KV服务器执行PUT请求,保存a:1
  5. KV服务器返回处理成功
  6. 客户端发送GET(a)请求
  7. KV服务器返回1给客户端

[tu6]

这里只需要拷贝到过半服务器即可。为什么不需要拷贝到所有的节点?因为我们想构建一个容错系统,所以即使某些服务器故障了,我们依然期望服务能够继续工作。所以只要过半服务器有了相应的拷贝,那么请求就可以提交。

提问:除了Leader节点,其他节点的应用程序层会有什么样的动作?

哦对,抱歉。当一个操作最终在Leader节点被提交之后,每个副本节点的Raft层会将相同的操作提交到本地的应用程序层。在本地的应用程序层,会将这个操作更新到自己的状态。所以,理想情况是,所有的副本都将看到相同的操作序列,这些操作序列以相同的顺序出现在Raft到应用程序的upcall中,之后它们以相同的顺序被本地应用程序应用到本地的状态中。假设操作是确定的(比如一个随机数生成操作就不是确定的),所有副本节点的状态,最终将会是完全一样的。我们图中的Key-Value数据库,就是Raft论文中说的状态(也就是Key-Value数据库的多个副本最终会保持一致)。

执行的时序图如下

[tu7]

提问:S2和S3的状态怎么保持与S1同步?

我的天,我忘了一些重要的步骤。现在Leader知道过半服务器已经添加了Log,可以执行客户端请求,并返回给客户端。但是服务器2还不知道这一点,服务器2只知道:我从Leader那收到了这个请求,但是我不知道这个请求是不是已经被Leader提交(committed)了,这取决于我的响应是否被Leader收到。服务器2只知道,它的响应提交给了网络,或许Leader没有收到这个响应,也就不会决定commit这个请求。所以这里还有一个阶段。一旦Leader发现请求被commit之后,它需要将这个消息通知给其他的副本。所以这里有一个额外的消息。

当多数副本确认消息以后,leader还需要发送确认更新的消息给其他副本,其他副本才会真正的执行操作。

实际上Raft没有一个单独的确认更新消息,而是将确认更新的操作放到了下一次的RPC请求中,比如心跳或者下一次的日志同步请求。

提问:这里的内部交互有点多吧?

是的,这是一个内部需要一些交互的协议,它不是特别的快。实际上,客户端发出请求,请求到达某个服务器,这个服务器至少需要与一个其他副本交互,在返回给客户端之前,需要等待多条消息。所以,一个客户端响应的背后有多条消息的交互。学生提问:也就是说commit信息是随着普通的AppendEntries消息发出的?那其他副本的状态更新就不是很及时了。
是的,作为实现者,这取决于你在什么时候将新的commit号发出。如果客户端请求很稀疏,那么Leader或许要发送一个心跳或者发送一条特殊的AppendEntries消息。如果客户端请求很频繁,那就无所谓了。因为如果每秒有1000个请求,那么下一条AppendEntries很快就会发出,你可以在下一条消息中带上新的commit号,而不用生成一条额外的消息。额外的消息代价还是有点高的,反正你要发送别的消息,可以把新的commit号带在别的消息里。
实际上,我不认为其他副本(非Leader)执行客户端请求的时间很重要,因为没有人在等这个步骤。至少在不出错的时候,其他副本执行请求是个不太重要的步骤。例如说,客户端就没有等待其他副本执行请求,客户端只会等待Leader执行请求。所以,其他副本在什么时候执行请求,不会影响客户端感受的请求时延。

Log

Log可以起到排序的作用,我有10个客户端同时向Leader发出请求,Leader必须对这些请求确定一个顺序,并确保所有其他的副本都遵从这个顺序。实际上,Log是一些按照数字编号的槽位(类似一个数组),槽位的数字表示了Leader选择的顺序。

Log的另一个用途是,在一个Follower副本收到了操作,但是还没有执行操作时。该副本需要将这个操作存放在某处,直到收到了Leader发送的新的commit号才执行。所以,对于Raft的Follower来说,Log是用来存放临时操作的地方。Follower收到了这些临时的操作,但是还不确定这些操作是否被commit了。我们将会看到,这些操作可能会被丢弃。

Log还可以用在Leader节点上,Leader需要在它的Log中记录操作,因为这些操作可能需要重传给Follower。如果一些Follower由于网络原因或者其他原因短时间离线了或者丢了一些消息,Leader需要能够向Follower重传丢失的Log消息。所以,Leader也需要保存Log。

所有节点都需要保存Log还有一个原因,就是它可以帮助重启的服务器恢复状态。你可能的确需要一个故障了的服务器在修复后,能重新加入到Raft集群,要不然你就永远少了一个服务器。

总结一下,Log的作用:

  • 排序所有的操作,使所有节点按照相同的顺序来完成这些操作
  • Follower节点临时存储操作,直到Commit信号到来
  • Leader节点用来重复传送Log信息
  • 所有节点需要把Log持久化,以便崩溃以后通过Log进行恢复

提问:假设Leader每秒可以执行1000条操作,Follower只能每秒执行100条操作,并且这个状态一直持续下去,会怎样?

这里有一点需要注意,Follower在实际执行操作前会确认操作。所以,它们会确认,并将操作堆积在Log中。而Log又是无限的,所以Follower或许可以每秒确认1000个操作。如果Follower一直这么做,它会生成无限大的Log,因为Follower的执行最终将无限落后于Log的堆积。 所以,当Follower堆积了10亿(不是具体的数字,指很多很多)Log未执行,最终这里会耗尽内存。之后Follower调用内存分配器为Log申请新的内存时,内存申请会失败。Raft并没有流控机制来处理这种情况。所以我认为,在一个实际的系统中,你需要一个额外的消息,这个额外的消息可以夹带在其他消息中,也不必是实时的,但是你或许需要一些通信来(让Follower)告诉Leader,Follower目前执行到了哪一步。这样Leader就能知道自己在操作执行上领先太多。所以是的,我认为在一个生产环境中,如果你想使用系统的极限性能,你还是需要一条额外的消息来调节Leader的速度。

提问:如果其中一个服务器故障了,它的磁盘中会存有Log,因为这是Raft论文中图2要求的,所以服务器可以从磁盘中的Log恢复状态,但是这个服务器不知道它当前在Log中的执行位置。同时,当它第一次启动时,它也不知道那些Log被commit了。

所以,对于第一个问题的答案是,一个服务器故障重启之后,它会立即读取Log,但是接下来它不会根据Log做任何操作,因为它不知道当前的Raft系统对Log提交到了哪一步,或许有1000条未提交的Log。

问题:如果Leader出现了故障会怎样?

如果Leader也关机也没有区别。让我们来假设Leader和Follower同时故障了,那么根据Raft论文图2,它们只有non-volatile状态(也就是磁盘中存储的状态)。这里的状态包括了Log和最近一次任期号(Term Number)。如果大家都出现了故障然后大家都重启了,它们中没有一个在刚启动的时候就知道它们在故障前执行到了哪一步。所以这个时候,会先进行Leader选举,其中一个被选为Leader。如果你回顾一下Raft论文中的图2有关AppendEntries的描述,这个Leader会在发送第一次心跳时弄清楚,整个系统中目前执行到了哪一步。Leader会确认一个过半服务器认可的最近的Log执行点,这就是整个系统的执行位置。另一种方式来看这个问题,一旦你通过AppendEntries选择了一个Leader,这个Leader会迫使其他所有副本的Log与自己保持一致。这时,再配合Raft论文中介绍的一些其他内容,由于Leader知道它迫使其他所有的副本都拥有与自己一样的Log,那么它知道,这些Log必然已经commit,因为它们被过半的副本持有。这时,按照Raft论文的图2中对AppendEntries的描述,Leader会增加commit号。之后,所有节点可以从头开始执行整个Log,并从头构造自己的状态。但是这里的计算量或许会非常大。所以这是Raft论文的图2所描述的过程,很明显,这种从头开始执行的机制不是很好,但是这是Raft协议的工作流程。下一课我们会看一种更有效的,利用checkpoint的方式。

Raft层接口

假设我们现在有一个KV服务器,下面是一个Raft层。如图所示

[tu8]

Raft层会提供一个Start接口,参数是客户端的请求,Start接口会开始执行Raft的工作,比如写入日志,并且同步给其他的副本。等待多数副本返回成功。

同样的,KV服务器也需要给Raft层提供一个接口,这里采用的不是一个接口,而是一个Channel,通过这个Channel,Raft可以通知KV服务器一些消息。

提问:为什么不在Start函数返回的时候就响应客户端请求呢?

我们假设客户端发送了任意的请求,我们假设这里是一个Put或者Get请求,是什么其实不重要,我们还是假设这里是个Get请求。客户端发送了一个Get请求,并且等待响应。当Leader知道这个请求被(Raft)commit之后,会返回响应给客户端。所以这里会是一个Get响应。所以,(在Leader返回响应之前)客户端看不到任何内容。这意味着,在实际的软件中,客户端调用key-value的RPC,key-value层收到RPC之后,会调用Start函数,Start函数会立即返回,但是这时,key-value层不会返回消息给客户端,因为它还没有执行客户端请求,它也不知道这个请求是否会被(Raft)commit。一个不能commit的场景是,当key-value层调用了Start函数,Start函数返回之后,它就故障了,所以它必然没有发送Apply Entry消息或者其他任何消息,所以也不能执行commit。所以实际上,Start函数返回了,随着时间的推移,对应于这个客户端请求的ApplyMsg从applyCh channel中出现在了key-value层。只有在那个时候,key-value层才会执行这个请求,并返回响应给客户端。[1]

Log的内容不一定是一直都一样的,它们中间有可能不一致,但是Raft保证所有日志的最终一致性。

Leader Election

为什么我们需要一个Leader?

  • 这里有很多原因,但是最重要的原因是这样做更加高效,所有节点服从Leader的安排。
  • 原始的Paxos就是无leader的模式,通过所有节点协商来决定,但是这样非常耗时。
  • 使用Leader的速度可能提升两倍,也更容易理解整个系统

Raft的生命周期中会有多个leader,这些leader也有自己的生命周期,被称为term(任期)。通过term number(任期编号)来表示一个任期,每一次新的任期都会+1.

对于Follower来说,不需要知道谁是leader,只需要知道当前的任期编号就可以了。

对于每个任期来讲,只会有0-1个leader,不可能在同一个任期内出现两个leader。

打个比方,你创建了一个小人国(Raft),大家选举你成为小人国第一任国王(term number = 1的leader)。你的国民就是你的追随者(Follower).每一任国王的任期,你规定为1年。
当你挂掉了或者任期到了,那么小人国就变成了一个拥有0个leader的小人国,这个时候,会有其他的国民竞选成为第二任国王(term number = 2的leader)
但是,不可能同时有两任国王一起出现。每一个国王在位的时候,他负责管理整个王国。

对于Raft来说,每一个节点都会有一个Election Time(任期定时器),当定时器到了,当前节点没有收到有leader的消息,这个节点就会变成一个Candidate,任期+1,从而开始竞选成为leader。

打个比方,小人国的第一个国王任期是一年(Election Time = 1年),当1年到了,国王卸任了,国民发现没有国王了,就会开始竞选成为下一任国王。

如果网络很慢,丢了几个心跳,或者其他原因,这时,尽管Leader还在健康运行,我们可能会有某个选举定时器超时了,进而开启一次新的选举。在考虑正确性的时候,我们需要记住这点。

所以这意味着,如果有一场新的选举,有可能之前的Leader仍然在运行,并认为自己还是Leader。

例如,当出现网络分区时,旧Leader始终在一个小的分区中运行,而较大的分区会进行新的选举,最终成功选出一个新的Leader。这一切,旧的Leader完全不知道。所以我们也需要关心,在不知道有新的选举时,旧的Leader会有什么样的行为?

打个比方,小人国的第一任国王带着几个国民出去打猎了,到了第二天还没有回来,就有其他人开始了竞选,选出了第二任国王,而第一任国王还带着人在外面打猎呢,根本不知道这个事。

提问:有没有可能出现极端的情况,导致单向的网络出现故障,进而使得Raft系统不能工作?

我认为是有可能的。例如,如果当前Leader的网络单边出现故障,Leader可以发出心跳,但是又不能收到任何客户端请求。它发出的心跳被送达了,因为它的出方向网络是正常的,那么它的心跳会抑制其他服务器开始一次新的选举。但是它的入方向网络是故障的,这会阻止它接收或者执行任何客户端请求。这个场景是Raft并没有考虑的众多极端的网络故障场景之一。我认为这个问题是可修复的。我们可以通过一个双向的心跳来解决这里的问题。在这个双向的心跳中,Leader发出心跳,但是这时Followers需要以某种形式响应这个心跳。如果Leader一段时间没有收到自己发出心跳的响应,Leader会决定卸任,这样我认为可以解决这个特定的问题和一些其他的问题。你是对的,网络中可能发生非常奇怪的事情,而Raft协议没有考虑到这些场景。[1]

每个Raft节点,在一个任期内,只能投出一个选票,这样的话,就不会有两个人同时获取到多数选票而成为leader。

如果出现了脑裂,那么只要有一个网络分区中有多数节点,那么依然可以选出一个leader来进行工作。

但是如果不存在多数节点的网络分区,那么就永远不可能选出一个leader,因此,服务是不可用的。

如果一次选举成功了,那么获胜的leader还需要通知其他的Follower,通知的方法是发送一个RPC请求。这里有一个巧妙的点,在于,Follower是不可能发送RPC请求的,能发送的只有Leader,因此,其他的Follower收到请求,就知道有人成为新的Leader了,但是具体是谁,它们是不知道的。也不需要知道。

当发送RPC请求的时候,会重置任期定时器。这样,leader在位的时候,就可以避免其他节点进行竞选。

Split Vote(分割选票)

Split Vote指的是:假设所有的节点同一时间发起竞选,并且都给自己投了一票,后果就是它们无法给其他人投票,因为每一个人只有一张选票。同样的,所有的节点都只获得了一个选票,没有任何一个节点获取了多数的选票,因此,无法选举出leader节点。

为了尽量避免这个问题,Raft采取了随机任期定时时间的方法。这样避免了同一时间,有多个节点进行竞选。如果每个节点开始竞选的时间不一样,就可以最大限度的避免Split Vote了。

在某个时间,所有的节点收到了最后一条RPC消息。之后,Leader就故障了。我们这里假设Leader在发出最后一次心跳之后就故障关机了。所有的Followers在同一时间重置了它们的选举定时器,因为它们大概率在同一时间收到了这条AppendEntries消息。它们都重置了自己的选举定时器,这样在将来的某个时间会触发选举。但是这时,它们为选举定时器选择了不同的超时时间。

因为不同的服务器都选取了随机的超时时间,总会有一个选举定时器先超时,而另一个后超时。

这里对于选举定时器的超时时间的设置,需要注意一些细节。

一个明显的要求是,选举定时器的超时时间需要至少大于Leader的心跳间隔。这里非常明显,假设Leader每100毫秒发出一个心跳,你最好确认所有节点的选举定时器的超时时间不要小于100毫秒,否则该节点会在收到正常的心跳之前触发选举。

所以,选举定时器的超时时间下限是一个心跳的间隔。实际上由于网络可能丢包,这里你或许希望将下限设置为多个心跳间隔。所以如果心跳间隔是100毫秒,你或许想要将选举定时器的最短超时时间设置为300毫秒,也就是3次心跳的间隔。

最大超时时间影响了系统能多快从故障中恢复。因为从旧的Leader故障开始,到新的选举开始这段时间,整个系统是瘫痪了。尽管还有一些其他服务器在运行,但是因为没有Leader,客户端请求会被丢弃。

  • 所以,这里的上限越大,系统的恢复时间也就越长。这里究竟有多重要,取决于我们需要达到多高的性能,以及故障出现的频率。
  • 不同节点的选举定时器的超时时间差必须要足够长,使得第一个开始选举的节点能够完成一轮选举。这里至少需要大于发送一条RPC所需要的往返(Round-Trip)时间。

或许需要10毫秒来发送一条RPC,并从其他所有服务器获得响应。如果这样的话,我们需要设置超时时间的上限到足够大,从而使得两个随机数之间的时间差极有可能大于10毫秒。

这里还有一个小点需要注意,每一次一个节点重置自己的选举定时器时,都需要重新选择一个随机的超时时间。也就是说,不要在服务器启动的时候选择一个随机的超时时间,然后反复使用同一个值。因为如果你不够幸运的话,两个服务器会以极小的概率选择相同的随机超时时间,那么你会永远处于分割选票的场景中。所以你需要每次都为选举定时器选择一个不同的随机超时时间。

第七讲 Raft(2)

假设如下场景:
我们有S1S2S3三个服务器,S3服务器是Leader,S1和S2是Follower。每个服务器有自己的Log。
S1的Log:

1
2
3
// Log前面的数据隐藏掉,Log[10]的内容是第3个term插入的
...
Log[10] = 3

S2的Log:

1
2
3
4
5
// Log前面的数据隐藏掉,Log[10]的内容是第3个term插入的,11的内容也是,12的内容是第4个term插入的,并且没有得到多数节点确认只有S2确认了。
...
Log[10] = 3
Log[11] = 3
Log[12] = 4

S3的Log:

1
2
3
4
5
// Log前面的数据隐藏掉,Log[10]的内容是第3个term插入的,11的内容也是,12的内容是第5个term插入的
...
Log[10] = 3
Log[11] = 3
Log[12] = 5

如图所示:

[tu9]

接下来S3服务器在term=6的时候插入了一个数据,发送AppendEntriesRPC请求给其他的服务器。消息中还会附带prevLogIndex代表S3服务器上一个LogIndex,也就是12.因为新插入的下标是13.prevLogTerm代表S3服务器上一个下标12的term也就是5.

  • Log[13] = 6
  • prevLogIndex = 12
  • prevLogTerm = 5

Followers在写入Log之前,会检查本地的前一个Log条目,是否与Leader发来的有关前一条Log的信息匹配。

  • S1服务器接收到请求,因为S1服务器的下标12里面没有数据,因此拒绝该请求。
  • S2服务器接收到请求,因为S2服务器的下标12里面的数据的term是4不是5,因此不匹配,拒绝该请求。

S3服务器发现两个Followers都拒绝了,继续重发。重发的时候下标会-1,也就是11,Term=3

  • S1服务器接收到请求,因为S1服务器的下标11里面没有数据,因此拒绝该请求。
  • 对于S2服务器接收到请求,发现下标11里面的数据term为3,能匹配上,因此采纳该请求,更新下标11的数据,和下标12的数据为最新的数据。

S2服务器更新后如下:

1
2
3
4
5
...
Log[10] = 3
Log[11] = 3
Log[12] = 5
Log[13] = 6

S3服务器发现S1服务器还是失败了,因此再次重发请求,下标-1,也就是10,term=3

  • S1服务器接收到请求,因为S1服务器的下标10里面的数据term为3,能匹配上,因此更新最新的数据放到Log里面。

S1服务器更新后如下:

1
2
3
4
5
...
Log[10] = 3
Log[11] = 3
Log[12] = 5
Log[13] = 6

这里有个注意的点,我们删除了S2服务器中原来的下标12的term=4的数据,为什么?

  • 因为那个数据不是来自Leader的,因此那是一个少数节点确认,而不是多数节点确认,也就是没有成功的数据,因此可以安全的删除,并最终以Leader的数据为准。

提问:前面的过程中,为什么总是删除Followers的Log的结尾部分?

一个备选的答案是,Leader有完整的Log,所以当Leader收到有关AppendEntries的False返回时,它可以发送完整的日志给Follower。如果你刚刚启动系统,甚至在一开始就发生了非常反常的事情,某个Follower可能会从第一条Log 条目开始恢复,然后让Leader发送整个Log记录,因为Leader有这些记录。如果有必要的话,Leader拥有填充每个节点的日志所需的所有信息。[1]

leader选举

首先,要明确一点,不是所有的节点都可以成为Leader的。

那我们为什么不使用Log最长的节点作为Leader?

我们来看这个场景:
假设有S1、S2、S3这三个服务器,一开始S1服务器是leader,任期是5.同步了一些log给S2和S3.

  1. 接下来S1进入了任期6,又成为了Leader。再次接收到请求,并同步Log给S2和S3,但是还没有同步就挂掉了。因此这些日志是没有执行成功的。
  2. 进入任期7,S1又好了,又成为了Leader。再次接收到请求,同步给S2和S3,还没同步又挂掉了,因此这些日志也是没有执行成功的。
  3. 进入任期8,S2服务器成为了Leader,并且同步数据给了S3服务器
  4. 进入任期9选举,S1服务器回来了。

这个时候S1服务器能成为Leader吗?

很显然是不行的,因为它都落后一个任期的数据了。可是S1服务器是Log最长的节点。因此我们不能使用Log最长的节点作为Leader。

[tu10]

根据论文中的描述,对于Leader选举的要求如下,满足任意一条即可:

  • Candidate节点的最后一个Log Entry的任期号大于本地最后一个Log Entry的任期号,也就是说Leader的Log必须是最新的,要比我本地的新才行。
  • Candidate节点的最后一个Log Entry的任期号等于本地最后一个Log Entry的任期号,并且Log的长度要大于等于本地Log的长度。如果Leader的Log和我本地的一样新,那数量至少要和我本地的数量一样才行。

只有满足了上述2个条件中的一个,才能获得选票,要不然还不如我来当选呢。

快速恢复

在上面的过程中,每次Follower节点同步失败以后,主节点都会回退一下日志的下标再次重试,但是这样很慢,如果要回退几百上千次甚至更多次呢?

因此,就有了快速恢复这个优化的方法。

Raft论文在论文的5.3结尾处,对一种方法有一些模糊的描述。原文有些晦涩,在这里我会以一种更好的方式尝试解释论文中有关快速恢复的方法。

我们之前回退的方法是以Log为维度,每次回退一个Log Entry,优化的思想是以Term为维度回退,每次回退一个Term,Follower将上一个Term返回给Leader。

可以让Follower在回复Leader的AppendEntries消息中,携带3个额外的信息,来加速日志的恢复。这里的回复是指,Follower因为Log信息不匹配,拒绝了Leader的AppendEntries之后的回复。这里的三个信息是指:

  • XTerm:这个是Follower中与Leader冲突的Log对应的任期号。在之前有介绍Leader会在prevLogTerm中带上本地Log记录中,前一条Log的任期号。如果Follower在对应位置的任期号不匹配,它会拒绝Leader的AppendEntries消息,并将自己的任期号放在XTerm中。如果Follower在对应位置没有Log,那么这里会返回 -1。[1]
  • XIndex:这个是Follower中,对应任期号为XTerm的第一条Log条目的下标。
  • XLen:如果Follower在对应位置没有Log,那么XTerm会返回-1,XLen表示空白的Log下标。

场景1:S1服务器是Follower,没有任期6的Log,只有任期4、5的Log。

tu11

S1服务器会返回XTerm=5,XIndex=2。S2服务器发现自己没有任期5的日志,它会将自己本地记录的,S1的nextIndex设置到XIndex,也就是S1中,任期5的第一条Log对应的槽位号。所以,如果Leader完全没有XTerm的任何Log,那么它应该回退到XIndex对应的位置(这样,Leader发出的下一条AppendEntries就可以一次覆盖S1中所有XTerm对应的Log)。

场景2:S1收到了任期4的旧Leader的多条Log,但是作为新Leader,S2只收到了一条任期4的Log。所以这里,我们需要覆盖S1中有关旧Leader的一些Log。

tu12

S1服务器会返回XTerm=4,XIndex=1。S2服务器发现自己其实有任期4的日志,它会将自己本地记录的S1的nextIndex设置到本地在XTerm位置的Log条目后面,也就是槽位2。下一次Leader发出下一条AppendEntries时,就可以一次覆盖S1中槽位2和槽位3对应的Log

场景3: S1与S2的Log不冲突,但是S1缺失了部分S2中的Log。

tu13

S1服务器会返回XTerm=-1,XLen=2。这表示S1中日志太短了,以至于在冲突的位置没有Log条目,Leader应该回退到Follower最后一条Log条目的下一条,也就是槽位2,并从这开始发送AppendEntries消息。槽位2可以从XLen中的数值计算得到。

提问:这里是线性查找,可以使用类似二分查找的方法进一步加速吗?

我认为这是对的,或许这里可以用二分查找法。我没有排除其他方法的可能,我的意思是,Raft论文中并没有详细说明是怎么做的,所以我这里加工了一下。或许有更好,更快的方式来完成。如果Follower返回了更多的信息,那是可以用一些更高级的方法,例如二分查找,来完成。为了通过Lab2的测试,你肯定需要做一些优化工作。我们提供的Lab2的测试用例中,有一件不幸但是不可避免的事情是,它们需要一些实时特性。这些测试用例不会永远等待你的代码执行完成并生成结果。所以有可能你的方法技术上是对的,但是花了太多时间导致测试用例退出。这个时候,你是不能通过全部的测试用例的。因此你的确需要关注性能,从而使得你的方案即是正确的,又有足够的性能。不幸的是,性能与Log的复杂度相关,所以很容易就写出一个正确但是不够快的方法出来。

提问:能在解释一下这里的流程吗?

这里,Leader发现冲突的方法在于,Follower会返回它从冲突条目中看到的任期号(XTerm)。在场景1中,Follower会设置XTerm=5,因为这是有冲突的Log条目对应的任期号。Leader会发现,哦,我的Log中没有任期5的条目。因此,在场景1中,Leader会一次性回退到Follower在任期5的起始位置。因为Leader并没有任何任期5的Log,所以它要删掉Follower中所有任期5的Log,这通过回退到Follower在任期5的第一条Log条目的位置,也就是XIndex达到的。

持久化

Raft服务器当前的状态需要进行持久化存储,只有这样,当崩溃以后才可以恢复状态。

需要持久化的信息有

  • Log数据,这是最重要的数据,肯定要持久化
  • Term:任期,这也是比较重要的。
  • votedFor:需要保存当前任期投票给谁了,要不然就有可能重复投票。毕竟,每个任期,服务器只有一张选票

剩下的一些数据都是不需要持久化的

对于持久化的数据来说,应该每次变更都需要持久化,要不然就有可能丢失数据。这也是一种权衡。

如果你发现,直到服务器与外界通信时,才有可能持久化存储数据,那么你可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。

众所周知,写入磁盘是一个很耗时的操作。

如果你想构建一个能每秒处理超过100个请求的系统,这里有多个选择。其中一个就是,你可以使用SSD硬盘,或者某种闪存。

所以,synchronous disk updates是为什么数据要区分持久化和非持久化(而非所有的都做持久化)的原因(越少数据持久化,越高的性能)。Raft论文图2考虑了很多性能,故障恢复,正确性的问题。

提问:当你写你的Raft代码时,你实际上需要确认,当你持久化存储一个Log或者currentTerm,这些数据是否实时的存储在磁盘中,你该怎么做来确保它们在那呢?

在一个UNIX或者一个Linux或者一个Mac上,为了调用系统写磁盘的操作,你只需要调用write函数,在write函数返回时,并不能确保数据存在磁盘上,并且在重启之后还存在。几乎可以确定(write返回之后)数据不会在磁盘上。所以,如果在UNIX上,你调用了write,将一些数据写入之后,你需要调用fsync。在大部分系统上,fsync可以确保在返回时,所有之前写入的数据已经安全的存储在磁盘的介质上了。之后,如果机器重启了,这些信息还能在磁盘上找到。fsync是一个代价很高的调用,这就是为什么它是一个独立的函数,也是为什么write不负责将数据写入磁盘,fsync负责将数据写入磁盘。因为写入磁盘的代价很高,你永远也不会想要执行这个操作,除非你想要持久化存储一些数据。

另一个常见方法是,批量执行操作。如果有大量的客户端请求,或许你应该同时接收它们,但是先不返回。等大量的请求累积之后,一次性持久化存储(比如)100个Log,之后再发送AppendEntries。如果Leader收到了一个客户端请求,在发送AppendEntries RPC给Followers之前,必须要先持久化存储在本地。因为Leader必须要commit那个请求,并且不能忘记这个请求。实际上,在回复AppendEntries 消息之前,Followers也需要持久化存储这些Log条目到本地,因为它们最终也要commit这个请求,它们不能因为重启而忘记这个请求。[1]

日志快照

这也是一种优化手段,使用快照可以快速恢复Raft服务器,要不然总不能从日志的一开始进行恢复吧,那么如果日志有非常多,恢复的速度就会很慢了。

快照背后的思想是,要求应用程序将其状态的拷贝作为一种特殊的Log条目存储下来。我们之前几乎都忽略了应用程序,但是事实是,假设我们基于Raft构建一个key-value数据库,Log将会包含一系列的Put/Get或者Read/Write请求。假设一条Log包含了一个Put请求,客户端想要将X设置成1,另一条Log想要将X设置成2,下一条将Y设置成7。

Log日志如下:

  • x = 1
  • x = 2
  • y = 7

对于Raft上面的KV数据库来说,内容如下:

  • x = 2
  • y = 7

因为Log是所有命令的集合,因此对于x这个key,可能有N多次修改,因此,就会有N条Log,但是上层的KV数据库里面只有一个数据而已。

所以,当Log非常大以后,Raft会将Log当前的状态做一个快照,有了快照以后,就可以删除这些无用的Log了。我们还需要为快照标注Log的槽位号.

[tu14]

这样的话,服务器崩溃恢复以后,就不再是依赖Log了,而是会先加载快照,然后再恢复快照之后的Log。

提问:快照的创建是否依赖应用程序?

肯定依赖。快照生成函数是应用程序的一部分,如果是一个key-value数据库,那么快照生成就是这个数据库的一部分。Raft会通过某种方式调用到应用程序,通知应用程序生成快照,因为只有应用程序自己才知道自己的状态(进而能生成快照)。而通过快照反向生成应用程序状态的函数,同样也是依赖应用程序的。但是这里又有点纠缠不清,因为每个快照又必须与某个Log槽位号对应。[1]

提问:如果RPC消息乱序该怎么处理?

是在说Raft论文图13的规则6吗?这里的问题是,你们会在Lab3遇到这个问题,因为RPC系统不是完全的可靠和有序,RPC可以乱序的到达,甚至不到达。你或许发了一个RPC,但是收不到回复,并认为这个消息丢失了,但是消息实际上送达了,实际上是回复丢失了。所有这些都可能发生,包括发生在InstallSnapshot RPC中。Leader几乎肯定会并发发出大量RPC,其中包含了AppendEntries和InstallSnapshot,因此,Follower有可能受到一条很久以前的InstallSnapshot消息。因此,Follower必须要小心应对InstallSnapshot消息。我认为,你想知道的是,如果Follower收到了一条InstallSnapshot消息,但是这条消息看起来完全是冗余的,这条InstallSnapshot消息包含的信息比当前Follower的信息还要老,这时,Follower该如何做?Raft论文图13的规则6有相应的说明。我认为正常的响应是,Follower可以忽略明显旧的快照。其实我(Robert教授)看不懂那条规则6。

Linear ability (线性能力)

线性能力你可能比较陌生,但是如果我说可串行化呢?其实这两个是一个东西。

线性能力的意思就是说,我们有多个服务器,在多个时刻里面,所执行的操作,需要和线形执行的是一致的,这样就是正确的,否则,就是错误的。

如果执行历史整体可以按照一个顺序排列,且排列顺序与客户端请求的实际时间相符合,那么它是对的。

一个具备线性能力的例子如下:

在某个时刻t1,客户端写入x=1,某个时刻t2,写入x=2,某个时刻t3,读取x=2,某个时刻t4,读取x=1,这里的t1,t2,t3,t4不代表前后关系。

它们真正的前后关系应该是t1 < t4 < t2 < t3。如果满足的话就是对的。如图。

[tu15]

每个读操作,得到的值,都必须是顺序中的前一个写操作写入的值。在上面的例子中,这个顺序是没问题的,因为这里的读看到的值的确是前一个写操作。读操作不能获取旧的数据,如果我写了一些数据,然后读回来,那么我应该看到我写入的值。

另一个反面教材如下:

在某个时刻t1,客户端写入x=1,某个时刻t2,写入x=2,某个时刻t3,读取x=2,某个时刻t4,读取x=1,这里的t1,t2,t3,t4不代表前后关系。

它们真正的前后关系 t1 < t3 < t2 < t4 ,很显然是错误的前后关系,因为t4不能发生在t2之后,已经读取x=2了,怎么又有客户端获取x=1呢?

如图,可以看到依赖产生了一个环,因此,这是一个错误的,不具有线性能力的系统。

[tu16]

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

参考资料

[1] https://www.zhihu.com/column/c_1273718607160393728

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

MySQL零基础教程

本教程为零基础教程,零基础小白也可以直接学习,有基础的可以跳到后面的原理篇学习。
基础概念和SQL已经更新完成。

接下来是应用篇,应用篇的内容大致如下图所示。

概念学习

表设计

表设计可以聊的点其实是比较多的,这个也比较看具体的业务、流量等。

比如,某一个字段是否应该放在这张表里?一张表里应该有哪些字段?如何设计字段类型?

甚至于,如何设计字段的顺序?

这里很多人不知道的一个点在于,字段的顺序也会影响性能,至于为什么,这个就偏低层一些了,下面会讲到。

想要做表设计,那你首先需要知道是什么,所以我们先来看看表到底是什么东西。

表是什么?

有的人会说,表就是Navicat上看到的一张表呗,还能是什么啊?

还有的人说,表就是一行一行数据组成的。

其实说的都对,但是这是逻辑上的表,也就是mysql给我们展现出来的表。

大家有没有想过,表的物理形式是什么,msyql如何将它转化成逻辑上的表方便我们查看呢?

接下来进行揭秘吧!

如何表示磁盘上文件的数据

数据库的数据最终以文件的形式放在磁盘中。通过文件读写将数据读写到文件中。文件有特定的格式,具体的内容有数据库进行解析然后展示在数据库中。这就是storage manager or storage engine

比如MySQL数据库的存储引擎,常用的就是InnoDB存储引擎了,他就是负责干这个事情的。

storage manager负责文件的读写工作。所有的文件(不管是一个或者多个)以 page 的形式存储,管理多个 page 组成的集合。

一个page就是一个固定大小的数据块。page 可以保存任何东西,tupe, metadata, indexes, log等等。每个page有唯一的ID,是page ID

有些page要求是独立的,自包含的(self-contained)。比如mysql的InnoDB。因为这样的话一个表的元数据和本身的数据内容在一起,如果发生问题的话,可以找回元数据和数据。如果元数据和数据在不同的page中,如果发生问题导致元数据的page丢失,那么数据则恢复不了了。

indirection layer记录page ID的相对位置,方便找到对应的偏移量。这样page目录就能找到对应的page。

不同的DBMS对于文件在磁盘上的存储方式不一样,有下面几种

  • 堆存储
  • 树存储
  • 有序文件存储(ISAM)
  • hashing文件存储

像MySQL数据库使用的就是堆存储的方式了,所以我们主要看一下什么是堆存储。

堆存储具有以下特点:

  • 无序的,保存的顺序和存储的顺序无关。
  • 需要读写page
  • 遍历所有的page
  • 需要元数据记录哪些是空闲的page,哪些是已经使用的page。
  • 使用 page directory 方式来记录文件位置。

page directory:说白了就是目录,记录了一些映射关系,在代码里面其实就是个Map。

  • 存储page ID和所在位置的关系
  • 存储page的空闲空间信息

大体结构如图所示:
010

page header:每个page里面都有一段空间用来存储这个page的一些相关信息,这段空间就叫做page header。

  • page 大小
  • checksum 校验和
  • DBMS版本信息
  • 事务可见性
  • 压缩信息

看到这里,其实你就明白了,我们在聊索引的时候,很多时候会说索引的叶子节点是一个page。不知道大家当时有没有疑问,这个page到底是什么?

在这里我就给你讲明白,这个page到底是个什么东西。

每个page里面除了包含page header以外,就是page data了。

这些page里面的数据就是我们能看到的一行行的数据。

我们通常称这样按照一行行数据来存储的数据库叫做行式数据库。比如MySQL就是。

除此之外还有一些按照一列列数据存储的列式数据库

数据表示

我们在做表设计的时候还会考虑到字段的类型,那么如何选择类型呢?

这些类型的底层存储有什么区别?

上面我们已经知道了一个page里面存储了一行行的数据。那么一行数据是多长呢?

这个一行数据的长度自然就是这一行数据所有字段长度的总和了。

字段的长度分为两种类型

  • 固定长度的字段
  • 可变长度的字段

固定长度的字段有下面这些:

  • Int:整型,当然了这里也包括TinyIntSmallIntBigInt等。不同长度的Int类型只是所占的字节数不一样而已。
  • Char:字符型,Char类型就是个字符串,你创建的时候给了多长,就是一个多长的字符串。
  • Decimal:定点小数,也是最常用的小数类型。虽然运算速度慢一些,但是精度高。Flout和Double虽然也是小数,运算速度快但是会有精度丢失的问题。
  • Date:时间类型,包括DateTimeTime等。固定长度用来存储时间。TimeStamp类型已经快要达到上限了,不要再使用了。

可变长度的字段有下面这些,他们的长度会存储在header里面:

  • Varchar: 可变的字符串类型,存储时只占用实际需要的字节数,对比Char类型而言,更加节省空间。
  • VarBinary: 可变长度的二进制字符串类型,类似于 VARCHAR,但存储的是二进制数据。
  • Text:可变长度的长文本类型,存储时占用的字节数取决于实际内容。
  • BLOB:可变长度的二进制数据类型,存储时占用的字节数取决于实际内容。

表设计中尤其要注意的几点:

不要去使用TimeStamp类型了,使用DateTime或者Bigint来存储时间。
其次,避免去使用Flout和Double类型,而是使用Decimal来存储小数,或者使用Int类型来存储。
避免使用大值存储,比如Text和blob类型,而是使用Varchar来代替。

通常说的大值(large values)也就是里面存储的内容过多。

比如我们有一个字段是Text类型,这个字段的数据非常大,占据了Page的一半,再加上其他的数据,那么我们一个Page里面只能存储一行数据了,Page里面剩下的空间就会浪费掉了。

对于这种情况,数据库的设计者也考虑到了,所以他们实际存储的是一个指针,这个指针指向另外一个Page页面。将这个字段的内容存储到另外一个单独的Page里面。这个单独的Page页面叫做Overflow Page

虽然这样解决了上面的问题,但是也引入了新的复杂度,比如对于这个额外的Page页面的维护管理。

NULL存储,表设计中还有重要的一点处理就是NULL值,因此我们通常把字段设置为NOT NULL类型来避免NULL值。因为NULL值有如下问题:

  • 行数据库通常是在Header里面增加bit map来判断是否是null
  • 列数据库通常使用占位符来标识NULL
  • 在每个属性前面增加bit来标识是否是NULL,这么做会破坏对齐,或增加存储空间,MySQL曾使用这个方法,后来抛弃了这个方法。
  • NULL == NULL 是 NULL, NULL is NULL 是 true

page是什么样子的

上面我们讲完了一行数据是什么样子的,那么一个Page里面又是什么样子的呢?

一般想法,就是一行贴着一行直接存储,新的行数据直接在后面追加,但是对于可变数据长度很难管理。

  • 记录page数,也就是page内部可插入的偏移量
  • 一个一个tupe按照顺序存储

007

所以,page内部,通常不使用上面那种,而使用的是slotted pages

  • slotted pages
    • slot array 存储插槽信息的偏移量,通过他找到对应的行数据
    • 支持可变长度的行数据
    • 但是会产生一些碎片空间,因为太小,一行数据放不下。
    • 压缩可以去除碎片空间,但是压缩的时候这个page就不能读写了。

008

mysql innodb 压缩

innodb 在写入的时候可以不解压,但是读取的时候会先在buffer pool中解压在读取。因此Mysql innodb的压缩的好处是提升空间利用率,减少了磁盘IO,缺点是读取的时候需要解压,因此增加了这部分的时间和CPU功耗以及解压以后会占用更多的内存空间。
innodb 默认page 是 16KB,可以压缩到1/2/4/8KB。

016

数据对齐

现代CPU是64位对齐,创建表以后,DBMS会自动的将数据进行对齐存储,不过,如果在创建表的时候考虑对齐,可以优化速度和存储空间。

012

还记得我们上面说过的吗,字段的顺序也是会影响性能的,如果你的字段顺序能满足数据对齐的要求,那么就可以避免空间的浪费,同一个page里面就可以存储更多的行数据,也就意味着我们每次获取一个page的时候,能从磁盘拿到更多的数据,因此我们获取大量数据的时候,IO次数就会减少,从而起到提升性能的效果。

实战

表设计要考虑的其实是比较多的,相信你看完上面的内容,对于如何设计表,应该有了一些自己的方法论。

这里再讲一下实战中需要注意的事情吧。

再开始设计表之前,我们肯定要先分析需求,然后才能知道我们需要存储哪些数据。

比如,我们要做一个招聘网站,那么我们需要存储发布的职位信息。

职位信息都放到一个表里面吗?

职位信息其实还挺多的,不光是常见的职位名称、职位JD。还会涉及到比如职级信息、工资信息、面试轮次,是否支持视频面试、学历要求、学校要求、工作经历要求大厂等。

这里面有一个冷热数据的概念。

比如说,有一部分信息是经常要查询的,比如职位名称、学历要求、工作年限要求、工资、公司信息、招聘人头像、名称、标签等。

首先、这些数据肯定放在多个表里面的,比如招聘人头像、名称是用户表的数据、标签信息是标签表的数据、公司信息是公司表的数据。但是其中职位名称、学历要求、工作年限、工资是属于职位的基本信息。而且是在职位列表、IM聊天中的职位卡片、职位详情、职位浏览、职位收藏等多个维度高曝光的职位信息。

那么就代表这些职位信息属于热数据,会被经常一起查询。因此他们放到一个表里面是没有问题的。

剩下的职位信息我们可以分成两类。

  1. 在职位详情页面首屏展示的内容或者一些强依赖的内容,这些数据也可以和上面的放在一个表里面。
  2. 一些其他不经常查询的数据,这些数据可以放在另外一个表里面。

这样的话,每次查询热数据的时候从第一张职位信息表获取,查询到的page里面都是包含的有用的信息,就可以减少IO次数。

因此,我们可以设计两个表来存储职位信息。

  • 职位信息表:存储职位的主要数据、热数据、提升查询速度。
  • 职位扩展信息表:存储职位的次要数据、冷数据、只在需要的时候进行查询。

总结

授人以鱼不如授人以渔,相信经过上面的学习,你已经具备了一定的表设计的能力了。

这里讲的主要是表的设计,而不是整个数据的设计。因为还缺少了一些,比如索引该如何设计?

如何保证大量数据的查询?

其实对于MySQL来说上索引以后就可以查询百万级的数据了,但是对于非常要求速度和更高量级的数据而言。还可以使用一些其他的方法,比如使用列式数据库来进行查询。

这样的话可能还会涉及数据同步、数据清洗等等。

相信你学完我的整个系列以后、对于更高量级的数据设计也会有一定的经验的。

如果在面试中遇到类似的问题,你也可以游刃有余的回答面试官。

在学习的过程中,我们也要做到知其然也知其所以然

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

MySQL零基础教程

本教程为零基础教程,零基础小白也可以直接学习,有基础的可以跳到后面的原理篇学习。
基础概念和SQL已经更新完成。

接下来是应用篇,应用篇的内容大致如下图所示。

概念学习

ER图

接下来讲讲ER图,这个是在实际工作中也会使用到的东西。

大家应该都听过ER图,全称是(Entity Relationship)实体关系图。

借用维基百科的介绍:

ER模型,全称为实体联系模型、实体关系模型或实体联系模式图(ERM)(英语:Entity-relationship model)由美籍台湾人计算机科学家陈品山发明,是概念数据模型的高层描述所使用的数据模型或模式图。

ER图通常用在设计数据库的阶段,当我们接到一个需求以后,我们要进行一些技术设计,在这个阶段如果涉及到一些对于数据库的修改,我们可以使用ER图进行设计。

万事万物都有两面性,因此我们看一下好处和坏处,如何使用由大家自己权衡。

  • 好处:好处是设计直观、在设计之后也方便给大家讲为什么这样设计,还有留存文档,当后续需要修改设计的时候直接修改即可。
  • 坏处:坏处就是需要花时间去画ER图了。

ER图的基本概念

ER图的主要组成部分

  • 实体(Entity):
    • 实体是数据库中具有相同属性集合的对象。例如,学生、课程、教师等。
    • 在ER图中,实体通常用矩形表示,矩形内写上实体的名称。
  • 属性(Attribute):
    • 属性是实体的特征或性质。例如,学生的属性可以包括学号、姓名、年龄等。
    • 在ER图中,属性通常用椭圆表示,椭圆内写上属性的名称,并用线连接到对应的实体。

简单来讲,实体就是一个表,属性就是表里面的一个字段。

下图就是一个ER图实体管理员用户属性有头像、密码、登录名、ID、邮箱、手机号。

概念学习

  • 关系(Relationship):
    • 关系描述了实体之间的联系。例如,学生和课程之间的关系可以是选修。
    • 在ER图中,关系通常用菱形表示,菱形内写上关系的名称,并用线连接到相关的实体。
  • 关系的类型:
    • 一对一关系(1:1):一个实体与另一个实体之间存在一对一的联系。例如,一个学生对应一个学号。
    • 一对多关系(1:N):一个实体与多个实体之间存在联系。例如,一个教师可以教授多个课程。
    • 多对多关系(M:N):多个实体与多个实体之间存在联系。例如,一个学生可以选修多个课程,一个课程也可以被多个学生选修。

下图就是一个ER图,实体是管理员用户和角色两个。关系拥有,表示管理员用户拥有角色的关系。关系的类型用m和n表示多对多关系。意思是一个管理员用户可以拥有多个角色,一个角色也可以被多个管理员用户拥有。

概念学习

关系这个东西,简单来说就是通过两个字段链接进行实现的。

多对多关系

比如我们有角色表和管理员用户表,这两个表,是多对多关系。

所以我们就需要用一张单独的表来存储这个关系。比如我们新建第三个表叫角色和管理员用户关系表。

角色和管理员用户关系表有字段内容如下:

  • id: 该表的主键。
  • roleId: 角色表的主键。用来关联角色信息。
  • adminId: 管理员用户表的主键。用来关联管理员用户信息。

可以看到,这个关系就是通过角色和管理员用户关系的两个字段来表示的。

角色表内容如下:

id name
1 前台角色
2 行政角色
3 开发角色

管理员用户表内容如下:

id name
1 张三
2 李四

角色和管理员用户关系表内容如下:

根据表的数据我们很清晰的能看出来,张三既拥有前台角色也拥有行政角色,可谓身兼数职。当代牛马。李四则拥有一个开发角色

id roleId adminId
1 1 1
2 2 1
3 1 2

记住上面说的,通过两个字段链接进行实现的。

这里的两个字段就是roleIdadminId

roleId = 1表示的是角色表中,id = 1前台角色,对应的数据是adminId = 1的数据,也就是管理员表中id = 1的管理员张三

如此,我们就知道了,张三拥有角色:前台角色。

一对多关系

一对多关系多对一关系基本一样。就是反过来了。

一个老师可以教授多个课程,比如我们有两个表,一个老师表,一个课程表

一对多关系比较灵活,记住上面说的,通过两个字段链接进行实现的。

先看老师表的数据如下:

id name
1 张老师
2 李老师

课程表的数据如下:

id name teacher_id
1 语文课程 1
2 数学课程 2
3 英语课程 1

为什么说一对多关系比较灵活呢,可以看到,我们这里用到的关联字段是老师表的id和课程表的teacher_id。而不需要再多加一张表了。

这里要记住一个重点!!!!
多出来的这个teacher_id 字段要放在一对多关系中的 多 的这个表里面。

什么是一对多关系中的的关系呢?

比如上面一个老师教多个课程,那么的关系就是课程信息。

当然了,如果你想要多加一张表来存储这两个字段的话也是可以的。

比如增加下表,教授课程表,如果增加了这张表,就可以把课程表中的teacher_id字段删除了:

下表也代表了张老师教授语文课程和英语课程,李老师教授数学课程。

id course_id teacher_id
1 1 1
2 2 2
3 3 1

一对一关系

一对一关系是最简单的。其实算是一对多关系的一个真子集

为什么这么说呢?

我们假设,一个老师只能教授一个课程,那么上面的两个表是不是就变成了一对一关系呢?所以我们说一对一关系是一对多关系的一个真子集。

ER图实践

接下来我们来实践一下ER图。

我这里使用的工具是免费的在线画图工具Processon

多对多关系的ER图

首先来画出多对多关系的ER图吧。

以上面的三个表为例子。

我们需要两个实体和一个关系。

  • 实体1: 管理员实体,管理员实体后面会转化成管理员表。
  • 实体2: 角色实体,角色实体后面会转化成角色表。
  • 关系:两个实体之间的多对多关系,多对多关系后期需要转换成一张关系表。

我们首先画出第一个实体,管理员实体使用矩形表示。如下图。

概念学习

接下来我们给管理员实体增加属性,属性使用椭圆来表示,如下图。

概念学习

现在我们画出第二个实体,角色实体和对应的属性。

概念学习

接下来我们画出关系,关系使用棱形来表示,并且使用线连接两个实体,表示是这两个实体之间的关系。多对多关系还需要在两边分别标记上nm

此外,关系还要有一个名称,用来描述,比如管理员拥有角色,角色被管理员拥有。所以我们可以用拥有关系来描述这两个实体之间的关系。

概念学习

如此,我们就完成了一个多对多关系的ER图。

一对多关系的ER图

接下来再实践一次一对多关系的ER图,最好大家先动手画一次,再跟我们的结果来对比,只有自己实操了才行。

实操时间。。。。。

好了,实操结束。

我们来看一下一对多关系如何画图。

我们需要两个实体和一个关系。

  • 实体1: 教师实体,后面变成教师表。
  • 实体2: 课程实体,后面变成课程表。
  • 关系:两个实体之间的一对多关系,一对多关系可以是一个字段也可以是一个表。

首先画出第一个实体和属性,也就是教师实体。

概念学习

接下来画出第二个实体和属性,也就是课程实体。课程实体这里有一个属性是教师ID,这代表我们使用这个字段,而不是增加了一个关系表。如果我们没有这个字段就代表我们要增加一个关系表。

概念学习

最后,画出关系并连线。这里关系名称我们选择教授,这个其实无所谓。能明白就好。重点是要标明一对多关系,可以看到教师实体上写的是1,课程实体上写的是n,代表一对多关系中,教师是1,课程是多。这个不能写反了。

概念学习

多对一关系的画起来是一样的,需要注意的就是关系上的1和n别写错了。

一对一关系的ER图

一对一关系的ER图和上面的差不多,我们举个例子吧。

  • 实体1: 用户实体,转化成用户表,存储用户信息。
  • 实体2: 用户账户实体,转化成用户账户表,存储用户的账号资金信息。
  • 关系:一对一关系,一个用户只能拥有一个用户账户。

首先画出第一个实体,用户实体。属性包括ID、用户名称、手机号。

概念学习

接下来画出第二个实体,用户账户实体,属性包括ID、账户余额、用户ID。用户ID作为关联关系的字段,和一对多关系一样,就不需要单独加表了。

概念学习

最后,画出关系并连线,标上关系是一对一关系就可以了。

概念学习

总结

通常来说,ER图是在设计阶段完成的,先有ER图再有表结构。

可如果你已经有了表结构,有没有办法生成ER图呢?

也是有方法的,比如著名的Navicat工具,就支持这么做。

此外,还有一个方法,就是使用在线工具dbdiagram,这个工具可以导入现有的SQL,会生成ER图,如下。

概念学习

这个网站是通过左边的一个叫dbml的语言来生成ER图的,也支持直接导入SQL,转化成dbml格式再生成ER图。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

什么是领域驱动设计DDD

领域驱动设计(Domain-Driven Design,简称DDD)是由美国软件专家埃里克・埃文斯(Eric Evans)在2004年提出的软件设计方法论,旨在解决复杂软件系统开发过程中业务逻辑与技术实现之间的矛盾,提升软件系统的可维护性、可扩展性和灵活性。

说人话就是:

  • What: 它是一种设计思想、一种指导原则。
  • When: 设计微服务的时候,或者说,不知道怎么拆分微服务的时候。
  • Why:为什么要用它,上面其实说了,不知道怎么拆分微服务的时候,可以用它来指导你如何拆分微服务。
  • How:这个后面讲。

很多人都说,DDD是用来处理复杂业务逻辑的,那多复杂才算复杂业务呢?

这个问题,其实和微服务什么时候用是一个问题。

所有的技术都不是银弹。都有适合它的使用场景。

拿微服务来说,你一个小公司,就两三个开发,硬要上微服务,拆好几个服务出来,有什么意义吗?

是提升性能了?

是增加开发效率了?

都不是,你会发现拆分完以后,程序反而三高了。

  • 高复杂度:程序变得更加复杂了。
  • 高维护成本:程序的维护成本增加了、当有需求需要修改的时候、开发效率反而降低了。
  • 高运维成本:原来一台机器就满足了,你拆的服务多了,一台机器不够了。要么加机器性能要么加机器数量。

所以,适合很重要。

俗话说的好,见人说人话,见鬼说鬼话。技术也一样。

基本概念

  • 实体:使用充血模型实现的实体,既有属性、也有方法。
  • 值对象:只有属性的类。
  • 聚合根:一个特殊的实体,聚合的入口。
  • 聚合:聚合是一个概念、也可以理解成一个模块。聚合内包含了聚合根、实体、值对象。
  • 限界上下文:分割领域的边界、也是分割微服务的边界,通过这个边界明确这个接口属于哪个领域,也就是属于哪个微服务。每个领域有每个领域的上下文。
  • 领域:领域也就是我们的领域模型,也可以是一个微服务。
  • 子领域:一个领域可以分成多个子领域。这个就是粒度的问题了。
  • 领域事件:领域之间通信的方法。通过这个来调用其他的微服务。

还有一些核心领域、支撑领域、通用领域等,都是领域的一种,作用不同而已。

领域

一个领域里面包含了多个子领域,如图所示。

ddd1-3

一个子领域里面包含了多个聚合,每个聚合里又有一个聚合根作为入口,还有若干个实体值对象

ddd1-4

那领域到底是什么?子领域又是什么?我们该如何划分领域?

如果大家看过仙侠小说、电视剧等,应该听过一些词,比如神之领域绝对领域恶魔的领地领主等等。

我们可以简单的把领域理解成领地、封地、画地为牢。

皇上将方圆百里的地分给你了,让你当这片封地的领主,那么这方圆百里就是你的领域了

那服务的领域是什么样子的呢?

同样的,我们把完整的服务想象成中国的领域,然后划分成各个省份。子领域就是每个省份下面的市区,再有子领域就是市区下面的县。

关于子领域也有一些划分,比如

  • 核心领域:核心领域是业务的核心竞争力所在,直接体现业务的独特价值。领域中的核心业务,核心竞争力,领域的重点。比如湖北省这个领域,他的核心子领域就是武汉市
  • 通用领域:通用领域是指那些在多个业务中都可以复用的领域,通常不包含业务特有的逻辑。一个领域中通用的能力,可以放到通用领域。比如湖北省这个领域,他的通用领域就有交通。因为不管是哪个市,下面都会用到交通,交通还可以划分为地铁、公交、高铁、火车等。
  • 支撑领域:既不属于核心领域、也不是通用领域。但是又必不可少的领域。为核心领域提供支持的领域,虽然重要但不直接体现业务的核心价值。

核心领域

特征:

  • 对业务成功至关重要。
  • 具有高复杂性和高价值。
  • 通常需要投入最多的资源和精力。
  • 一个领域中的核心,业务的重点发展对象。

为什么现在很多人开始使用DDD?无外乎是因为DDD更加契合业务,可以随着业务形态的改变,来带动代码的改变。

比如一个电商公司,一开始是以产品质量作为卖点,那么他们的核心领域就是产品质量领域。或者说商品领域。而商品领域再进行划分子领域,他们的核心子领域就是质量领域

而另外一家公司虽然也是电商公司,但是主要依靠广告业务赚钱,那么他们的核心领域就是广告领域

同样的,核心领域是会变化的,比如第一家公司一开始的产品卖点是质量过硬。后来做大了以后主要依靠流量的增涨来扩大收益,那么核心领域就会变为流量领域

在演进到后面,可能如何为用户推荐合适的商品来促成交易就变成了业务重点,那么这个时候的核心领域就是推荐算法领域了。

具体哪个领域是核心领域,需要大家一起讨论、深入了解公司的业务。

这样才能决定出核心领域。

通用领域

特征:

  • 低复杂性,易于复用。
  • 通常可以通过第三方工具或框架实现。
  • 不需要大量定制开发。

通用领域顾名思义,就是提供一些通用能力的领域,比如发送消息、权限认证等等。

这个根据公司的业务来看的。

比如一些公司需要签合同使用电子签章,那么这也算是一个通用的功能,可以是一个通用领域。

再比如常用的限流功能。也可以是一个通用领域。

支撑领域

特征:

  • 中等复杂性和中等价值。
  • 主要用于辅助核心领域的实现。
  • 可以通过一定程度的定制开发满足需求。
  • 支撑领域既不属于核心领域

支撑领域和通用领域不同的点就在于它和业务有一些关系。

比如,对于电商公司来说,核心可能是商品领域,那么为商品领域提供支撑的业务领域就是订单领域库存领域物流领域等。

如何划分不同的领域

  • 关注业务价值
    • 优先识别对业务成功最重要的部分,这通常是核心领域。
    • 核心领域的设计和实现需要投入最多的资源。
  • 关注领域的独立性
    • 每个领域应尽量独立,避免领域之间的强耦合。
    • 独立性强的领域更容易划分为限界上下文(Bounded Context)。
  • 关注领域的复用性
    • 通用领域应尽量复用现有的工具或框架,避免重复开发。
    • 支撑领域可以适当定制,但不应过度复杂化。
  • 动态调整
    • 领域划分并非一成不变,随着业务发展和需求变化,领域的类型可能需要重新评估和调整。

领域的划分是前期的重要工作,只有领域划分好了,后续的路才好走,如果领域划分的不对,那么后续还需要重构,就会比较浪费时间。

一般来说,领域划分都是首先通过事件风暴来进行的,参与整个事件暴风的人包括领域专家、业务专家、研发、架构师、产品等等。

须知人力有尽时,一个人的想法终归是有限的,只有集思广益,才是长久之道。大家在事件风暴之中应该畅所欲言、没有上下级的关系,如此方可,要不然很多人摄于上级的压迫,不敢说话,或是说了以后被上级否定,这样的话是不利于事件风暴进行的。

在事件风暴之中应该鼓励大家发言,不要去盲目的否定,更不要搞什么一言堂。

通过事件风暴我们可以识别出领域、子领域,还有聚合、事件等。

接下来我们就需要判断哪些是核心领域,哪些是通用领域,哪些是支撑领域。

有些人可能会问,为什么要划分出核心领域、支撑领域、通用领域呢?

这是因为大家的精力有限,要把大部分的精力投入到最重要的事情上去,也就是放到核心领域的建设上面。至于通用领域和支撑领域,如果人手不够也可以通过外包解决。

毕竟,核心领域才是最重要的领域。

上面也说过了,公司的不同时期,关注的业务重点不同,那么核心领域也会变化。

同样的,哪怕都是电商公司,他们的核心领域也不一定一样,一切都要看具体的业务。

而我们的重点就是要放在核心领域的建设上面。

总结

我们主要介绍了领域的概念,包括如何划分,还有核心领域、通用领域以及支撑领域等。还介绍了一些DDD的基本概念。

领域的核心思想就是两点

  • 将我们的业务自顶向下的进行细分,逐步的拆解。
  • 划分出不同的领域,将我们有限的精力投入到最重要的事情当中。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

什么是领域驱动设计DDD

领域驱动设计(Domain-Driven Design,简称DDD)是由美国软件专家埃里克・埃文斯(Eric Evans)在2004年提出的软件设计方法论,旨在解决复杂软件系统开发过程中业务逻辑与技术实现之间的矛盾,提升软件系统的可维护性、可扩展性和灵活性。

说人话就是:

  • What: 它是一种设计思想、一种指导原则。
  • When: 设计微服务的时候,或者说,不知道怎么拆分微服务的时候。
  • Why:为什么要用它,上面其实说了,不知道怎么拆分微服务的时候,可以用它来指导你如何拆分微服务。
  • How:这个后面讲。

很多人都说,DDD是用来处理复杂业务逻辑的,那多复杂才算复杂业务呢?

这个问题,其实和微服务什么时候用是一个问题。

所有的技术都不是银弹。都有适合它的使用场景。

拿微服务来说,你一个小公司,就两三个开发,硬要上微服务,拆好几个服务出来,有什么意义吗?

是提升性能了?

是增加开发效率了?

都不是,你会发现拆分完以后,程序反而三高了。

  • 高复杂度:程序变得更加复杂了。
  • 高维护成本:程序的维护成本增加了、当有需求需要修改的时候、开发效率反而降低了。
  • 高运维成本:原来一台机器就满足了,你拆的服务多了,一台机器不够了。要么加机器性能要么加机器数量。

所以,适合很重要。

俗话说的好,见人说人话,见鬼说鬼话。技术也一样。

基本概念

  • 实体:使用充血模型实现的实体,既有属性、也有方法。
  • 值对象:只有属性的类。
  • 聚合根:一个特殊的实体,聚合的入口。
  • 聚合:聚合是一个概念、也可以理解成一个模块。聚合内包含了聚合根、实体、值对象。
  • 限界上下文:分割领域的边界、也是分割微服务的边界,通过这个边界明确这个接口属于哪个领域,也就是属于哪个微服务。每个领域有每个领域的上下文。
  • 领域:领域也就是我们的领域模型,也可以是一个微服务。
  • 子领域:一个领域可以分成多个子领域。这个就是粒度的问题了。
  • 领域事件:领域之间通信的方法。通过这个来调用其他的微服务。

还有一些核心领域、支撑领域、通用领域等,都是领域的一种,作用不同而已。

聚合

总结

我们主要介绍了领域的概念,包括如何划分,还有核心领域、通用领域以及支撑领域等。还介绍了一些DDD的基本概念。

领域的核心思想就是两点

  • 将我们的业务自顶向下的进行细分,逐步的拆解。
  • 划分出不同的领域,将我们有限的精力投入到最重要的事情当中。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习