0%

问题记录

前段时间给 github page 换了一个 hexo 主题,换成了 next 主题,但是在更换主题之后,发现 travis 上的 CI 无法跑过了,查看日志内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
$ node --version
v6.9.4
$ npm --version
3.10.10
$ nvm --version
0.37.2
before_install.1
0.01s$ export TZ='Asia/Shanghai'
before_install.2
6.90s$ npm install -g hexo
before_install.3
2.81s$ npm install -g hexo-cli
install
1.54s$ npm install
before_script.1
0.01s$ git config --global user.name "Win-Man"
before_script.2
0.00s$ git config --global user.email 825895587@qq.com
before_script.3
0.00s$ sed -i'' "s~git@github.com:Win-Man/win-man.github.io.git~https://${CI_TOKEN}@github.com/Win-Man/win-man.github.io.git~" _config.yml
0.67s$ hexo clean
The command "hexo clean" exited with 0.
1.17s$ hexo generate
INFO Start processing
FATAL Something's wrong. Maybe you can find the solution here: https://hexo.io/docs/troubleshooting.html
TypeError: Object.values is not a function
at points.views.forEach.type (/home/travis/build/Win-Man/win-man.github.io/themes/next/scripts/events/lib/injects.js:82:46)
at Array.forEach (native)
at module.exports.hexo (/home/travis/build/Win-Man/win-man.github.io/themes/next/scripts/events/lib/injects.js:67:16)
at Hexo.hexo.on (/home/travis/build/Win-Man/win-man.github.io/themes/next/scripts/events/index.js:9:27)
at emitNone (events.js:86:13)
at Hexo.emit (events.js:185:7)
at Hexo._generate (/home/travis/build/Win-Man/win-man.github.io/node_modules/hexo/lib/hexo/index.js:399:8)
at loadDatabase.then.then (/home/travis/build/Win-Man/win-man.github.io/node_modules/hexo/lib/hexo/index.js:249:22)
at tryCatcher (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/util.js:16:23)
at Promise._settlePromiseFromHandler (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise.js:547:31)
at Promise._settlePromise (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise.js:604:18)
at Promise._settlePromise0 (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise.js:649:10)
at Promise._settlePromises (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise.js:729:18)
at Promise._fulfill (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise.js:673:18)
at PromiseArray._resolve (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise_array.js:127:19)
at PromiseArray._promiseFulfilled (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise_array.js:145:14)
at Promise._settlePromise (/home/travis/build/Win-Man/win-man.github.io/node_modules/bluebird/js/release/promise.js:609:26)
The command "hexo generate" exited with 2.
cache.2
store build cache

但是在我本地电脑执行 hexo g,hexo s,hexo d 都没啥问题,怀疑到了 node js 版本的问题,因为这个 repo 中的 .travis.yml 配置文件还是好几年前的了,于是尝试修改 node js 版本与本地版本一致。修改版本并提交之后还是有报错,但是报错信息变了。

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
$ node --version
v13.11.0
$ npm --version
6.13.7
$ nvm --version
0.37.2
before_install.1
0.01s$ export TZ='Asia/Shanghai'
before_install.2
5.40s$ npm install -g hexo
3.16s$ npm install -g hexo-cli
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@~2.3.1 (node_modules/hexo-cli/node_modules/chokidar/node_modules/fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.3.2: wanted {"os":"darwin","arch":"any"} (current: {"os":"linux","arch":"x64"})
npm ERR! code EEXIST
npm ERR! syscall symlink
npm ERR! path ../lib/node_modules/hexo-cli/bin/hexo
npm ERR! dest /home/travis/.nvm/versions/node/v13.11.0/bin/hexo
npm ERR! errno -17
npm ERR! EEXIST: file already exists, symlink '../lib/node_modules/hexo-cli/bin/hexo' -> '/home/travis/.nvm/versions/node/v13.11.0/bin/hexo'
npm ERR! File exists: /home/travis/.nvm/versions/node/v13.11.0/bin/hexo
npm ERR! Remove the existing file and try again, or run npm
npm ERR! with --force to overwrite files recklessly.
npm ERR! A complete log of this run can be found in:
npm ERR! /home/travis/.npm/_logs/2021-04-12T06_49_56_362Z-debug.log
The command "npm install -g hexo-cli" failed and exited with 239 during .

根据错误信息查找到一个链接
https://segmentfault.com/a/1190000018759308

解决方案

参考链接中的结果方案在 npm install 中加上 -f 选项。
附上完整的 .travis.yml 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
language: node_js
node_js:
- '13.11.0'
branches:
only:
- dev
cache:
directories:
- node_modules
before_install:
- export TZ='Asia/Shanghai'
- npm install -g hexo
- npm install -g hexo-cli -f
before_script:
- git config --global user.name "Win-Man"
- git config --global user.email 825895587@qq.com
- sed -i'' "s~git@github.com:Win-Man/win-man.github.io.git~https://${CI_TOKEN}@github.com/Win-Man/win-man.github.io.git~" _config.yml
install:
- npm install -f
script:
- hexo clean
- hexo generate
after_success:
- hexo deploy

原文地址:https://cstack.github.io/db_tutorial/parts/part1.html
原文作者: cstack
译者:Win-Man

介绍并设置 REPL

作为一个 Web 开发人员,我每天的工作中都会使用到关系型数据库,但是这些数据库对于我来说就是一个黑盒。因此我有一些疑问:

  • 在内存中、在磁盘上数据存储格式是怎么样的?
  • 什么时候数据库从内存转移到磁盘?
  • 为什么每个表只能有一个主键?
  • 事务回滚是如何工作的?
  • 索引的格式是怎么样的?
  • 什么时候会发生全表扫以及全表扫是如何工作的?
  • 预处理语句是以什么形式存储的?

简而言之,问题就是数据库是怎么工作的?

为了搞清楚这些问题,我从头开始写了一个数据库。它是模拟 sqlite 数据库实现的,因为 sqlite 设计的就是一个比 MySQL 或者 PostgreSQL 特性更少的数据库。所以我更有希望理解它。这整个数据库都存储在一个文件中。

Sqlite

sqlite 官网上有很多关于 sqlite 内核的文档,除此之外我还有一份资料 SQLite Database System: Design and Implementation

sqlite architecture(https://www.sqlite.org/zipvfs/doc/trunk/www/howitworks.wiki)

一个查询如果要获得数据或者修改数据的话,需要经过一系列的组件。前端包括:

  • tokenizer
  • parser
  • code generator

前端的输入是一个 SQL 查询. 输出的是 sqlite 虚拟机字节码(本质上是一个可以在数据库上操作的编译程序)

后端包括:

  • virtual machine
  • B-tree
  • pager
  • os interface

virtual machine 虚拟机接收前端生成的字节码作为指令。这些指令可以操作一个或多个表、索引,表和索引都是以 B 树的数据结构存储的。虚拟机本质上是一个大的语句与字节码指令转换器。

每棵 B-tree 包含很多节点。每个节点的长度是一页。B 树可以从磁盘上读取一页的数据或者通过命令将数据写回到数据库。

pager 组件接收读取或者写入数据的指令。它需要在正确的数据库数据文件位置写入或者读取数据,也需要将最近访问到的数据页缓存到内存中,并且决定什么时候将这些数据缓存页写到磁盘上。

os interface 操作系统接口层取决于 sqlite 是在哪个操作系统层编译的。在这个课程中,我不会支持多个操作系统平台。

千里之行,始于足下,让我们从一些比较简单的内容开始:REPL(交互式顶层构件)。

实现一个简单的 REPL

当你从命令行启动 sqlite client 的时候,会启动一个循环读取命令并执行命令:

1
2
3
4
5
6
7
8
9
10
~ sqlite3
SQLite version 3.16.0 2016-11-04 19:09:39
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> create table users (id int, username varchar(255), email varchar(255));
sqlite> .tables
users
sqlite> .exit
~

为了实现这个,我们的主函数会有一个无限输出提示符的循环,它会从读取一行输入,然后处理一行输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char* argv[]) {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);

if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
}
}

我们需要定义一个 InputBuffer 结构体来存储 getline() 函数得到的内容及信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct {
char* buffer;
size_t buffer_length;
ssize_t input_length;
} InputBuffer;

InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = (InputBuffer*)malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;

return input_buffer;
}

print_prompt() 函数用于输出提示符。我们需要在读取输入之前需要打印提示符。

1
void print_prompt() { printf("db > "); }

通过 getline() 函数读取一行输入

1
ssize_t getline(char **lineptr, size_t *n, FILE *stream);

lineptr: 存储输入内容的缓冲区地址指针。如果被 getline() 函数 mallocated 了 NULL 值,那么用户需要手动释放该空间,即使命令执行失败了。

n:存储分配的缓冲区大小值的变量指针

stream: 读取输入流,我们会从标准输入中读取。

返回值:读取的字节数,有可能比缓冲区大小小。

我们通过 getline() 函数,将读取的输入存储在 input_buffer->buffer 中,分配的缓冲区大小存储在 input_buffer->buffer_length 中。并且将返回结构存储在 input_buffer->input_length

初始环境下,缓冲区是空的,所以 getline 需要分配足够的内存用于存放输入并将指针指向缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
void read_input(InputBuffer* input_buffer) {
ssize_t bytes_read =
getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);

if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}

// Ignore trailing newline
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}

然后就该定义一个函数用来释放 InputBuffer * 实例的内存以及相应结构中的元素内存(getline 会在 read_input 的时候分配内存给 input_buffer->buffer)

1
2
3
4
void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer);
free(input_buffer);
}

最后,我们需要解析并执行命令。目前这只能识别一个命令 .exit,用于退出程序。其他的输入命令我们会输出一个错误然后继续读取新的输入。

1
2
3
4
5
6
if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}

来尝试一下!

1
2
3
4
5
~ ./db
db > .tables
Unrecognized command '.tables'.
db > .exit
~

好了,我们现在有了一个可以使用的 REPL 程序。在下一部分中,我们会开始开发我们的命令语言。同时,在这给出这个部分的完整程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
char* buffer;
size_t buffer_length;
ssize_t input_length;
} InputBuffer;

InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;

return input_buffer;
}

void print_prompt() { printf("db > "); }

void read_input(InputBuffer* input_buffer) {
ssize_t bytes_read =
getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);

if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}

// Ignore trailing newline
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}

void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer);
free(input_buffer);
}

int main(int argc, char* argv[]) {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);

if (strcmp(input_buffer->buffer, ".exit") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command '%s'.\n", input_buffer->buffer);
}
}
}

原文地址:https://cstack.github.io/db_tutorial/
原文作者: cstack
译者:Win-Man

一个数据库是如何工作的?

  • 在内存中、在磁盘上数据存储格式是怎么样的?
  • 什么时候数据库从内存转移到磁盘?
  • 为什么每个表只能有一个主键?
  • 事务回滚是如何工作的?
  • 索引的格式是怎么样的?
  • 什么时候会发生全表扫以及全表扫是如何工作的?
  • 预处理语句是以什么形式存储的?

简而言之,问题就是数据库是怎么工作的?

为了搞明白这个问题,我用 C 写了一个 sqlite 的克隆版本,并且记录了我的整个过程。

目录

“What I cannot create, I do not understand” - Richard Feynman

sqlite architecture

sqlite architecture (https://www.sqlite.org/arch.html)

MySQL query rewrite

介绍

MySQL 从 5.7.6 版本开始支持 SQL 改写的功能,对于符合条件的 SQL 可以进行对应的修改。在 8.0.12 之前的版本只支持 SELECT 语句的改写,8.0.12 版本开始支持 SELECT/INSERT/REPLACE/UPDATE/DELETE 语句的改写。

Query Rewrite Plugin 安装

安装 Query Rewrite Plugin 直接通过运行 install_rewriter.sql 中的 SQL 来进行安装即可,如果需要卸载的话,执行 uninstall_rewriter.sql 就可以,这两个 sql 文本文件存放在安装目录的 share 目录下。

  • 执行 install_rewriter.sql 安装插件
1
# mysql -uroot -p -h127.0.0.1 < install_rewriter.sql
  • 通过变量确认插件开启
1
2
3
4
5
6
7
root@127.0.0.1 : (none) 03:53:58> SHOW GLOBAL VARIABLES LIKE 'rewriter_enabled';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| rewriter_enabled | ON |
+------------------+-------+
1 row in set (0.00 sec)
  • 如果需要每次重启 rewriter_enabled 参数都是开启的话,可以在配置文件中配置对应参数
1
2
[mysqld]
rewriter_enabled=ON
  • 动态修改参数可以通过以下方式开启或者关闭插件,在刚安装完插件的时候,默认是开启的
1
2
SET GLOBAL rewriter_enabled = ON;
SET GLOBAL rewriter_enabled = OFF;

安装完 Query Rewrite Plugin 插件之后自从创建一个 query_rewrite 的 database,该 database 下有一张 rewrite_rules 表,用于记录对应的改写规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@127.0.0.1 : query_rewrite 06:48:28> show create table query_rewrite.rewrite_rules\G
*************************** 1. row ***************************
Table: rewrite_rules
Create Table: CREATE TABLE `rewrite_rules` (
`id` int NOT NULL AUTO_INCREMENT,
`pattern` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`pattern_database` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`replacement` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`enabled` enum('YES','NO') CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'YES',
`message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`pattern_digest` varchar(64) DEFAULT NULL,
`normalized_pattern` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

Query Rewrite Plugin 使用

使用语句改写的话,只需要将改写规则写入到 rewrite_rules 表中并且通过 CALL query_rewrite.flush_rewrite_rules() 加载生效即可。

比如将 SELECT ? 语句改写为 SELECT ? + 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
root@127.0.0.1 : query_rewrite 07:32:41> INSERT INTO query_rewrite.rewrite_rules (pattern, replacement)
-> VALUES('SELECT ?', 'SELECT ? + 1');
Query OK, 1 row affected (0.01 sec)

root@127.0.0.1 : query_rewrite 07:32:51> CALL query_rewrite.flush_rewrite_rules();
Query OK, 1 row affected (0.01 sec)

root@127.0.0.1 : query_rewrite 07:33:01> select 2;
+-------+
| 2 + 1 |
+-------+
| 3 |
+-------+
1 row in set, 1 warning (0.00 sec)

Note (Code 1105): Query 'select 2' rewritten to 'SELECT 2 + 1' by a query rewrite plugin

SQL 改写的话没有语句类型的限制,改写前的语句类型可以跟改写后的语句类型不一致,比如可以将 SELECT 语句改写为 INSERT 语句。如果调用 CALL query_rewrite.flush_rewrite_rules(); 使改写规则生效的时候报错了,可以查看 rewrite_rules 表中对应的 message 字段,会有具体的错误信息提示。

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
root@127.0.0.1 : query_rewrite 07:34:51> use gangshen;
Database changed
root@127.0.0.1 : gangshen 07:37:13> create table t(id int primary key,name varchar(20));
Query OK, 0 rows affected (0.03 sec)

root@127.0.0.1 : gangshen 07:37:36> INSERT INTO query_rewrite.rewrite_rules (pattern, replacement) VALUES('SELECT * from t', 'insert into t values(3,"aa")');
Query OK, 1 row affected (0.01 sec)

root@127.0.0.1 : gangshen 07:39:12> CALL query_rewrite.flush_rewrite_rules();
ERROR 1644 (45000): Loading of some rule(s) failed.
root@127.0.0.1 : gangshen 07:39:26> select * from query_rewrite.rewrite_rules;
+----+-----------------+------------------+------------------------------+---------+--------------------------------------------------+------------------------------------------------------------------+--------------------+
| id | pattern | pattern_database | replacement | enabled | message | pattern_digest | normalized_pattern |
+----+-----------------+------------------+------------------------------+---------+--------------------------------------------------+------------------------------------------------------------------+--------------------+
| 2 | SELECT ? | NULL | SELECT ? + 1 | YES | NULL | d1b44b0c19af710b5a679907e284acd2ddc285201794bc69a2389d77baedddae | select ? |
| 3 | SELECT * from t | NULL | insert into t values(3,"aa") | YES | Parse error in pattern: >>No database selected<< | NULL | NULL |
+----+-----------------+------------------+------------------------------+---------+--------------------------------------------------+------------------------------------------------------------------+--------------------+
2 rows in set (0.00 sec)

root@127.0.0.1 : gangshen 07:39:36> update query_rewrite.rewrite_rules set pattern='SELECT * from gangshen.t' where id = 3;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

root@127.0.0.1 : gangshen 07:40:18> update query_rewrite.rewrite_rules set replacement='insert into gangshen.t values(3,"aa")' where id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

root@127.0.0.1 : gangshen 07:40:50> CALL query_rewrite.flush_rewrite_rules();
Query OK, 1 row affected (0.01 sec)

root@127.0.0.1 : gangshen 07:40:54> select * from gangshen.t limit 5;
Empty set (0.00 sec)

root@127.0.0.1 : gangshen 07:41:55> select * from gangshen.t;
Query OK, 1 row affected, 1 warning (0.00 sec)

Note (Code 1105): Query 'select * from gangshen.t' rewritten to 'insert into gangshen.t values(3,"aa")' by a query rewrite plugin
root@127.0.0.1 : gangshen 07:42:00> select * from gangshen.t limit 5;
+----+------+
| id | name |
+----+------+
| 3 | aa |
+----+------+
1 row in set (0.00 sec)

Query Rewrite Plugin 的限制

  • 8.0.12 之前的版本只支持 SELECT 语句的改写,8.0.12 版本开始支持 SELECT/INSERT/REPLACE/UPDATE/DELETE 语句的改写
  • 只支持单独语句以及 prepare 语句的改写,视图或者存储过程相关的语句无法改写
  • 改写没有语句类型的限制,比如 SELECT 语句可以改写为 INSERT 语句

Query Rewrite Plugin 场景及总结

个人能想到的 SQL 改写可以使用到的场景:

  1. 对于危险 SQL 比如 delete from table_name 这种,可以做到拦截,避免不小心将表数据完全删除
  2. 业务上线后,发现某条 SQL 对数据库造成很大压力影响,可以将该 SQL 改写为 SELECT 1 这种语句,临时降低对数据库的负载
  3. 数据库升级之后,SQL 执行计划不正确导致数据库负载过高,在不修改业务代码的情况下,可以通过 SQL 改写的方式让 SQL 走更优的执行计划
  4. 历史库场景,过滤 DELETE 操作
  5. SQL 改写可以理解为简单的触发器功能,可以参考 pt-osc 的方式支持在线字段变更

2020 年终总结

每次在到了年底回顾的时候,最初的反应都是,这一年又过去了,好像什么也没干啊。但是仔细翻翻过去一年的记录,发现还是发生过很多事情的,但是有意义或者印象深刻的不多。如果利用好时间,一年时间可以做很多事情。 2020 年,以疫情轰轰烈烈地作为开头开始了不一样的一年。年初的时候因为疫情的原因,第一次过年没有走亲戚,大家都老老实实在家待着,跟家人待在一起,上一次这么长时间在家,应该需要追溯到高考结束那个暑假了吧。从上大学开始到工作,都没有这么长时间的在家了。也因为疫情的原因体验了一把在家远程上班的感觉,比去办公室上班累多了,因为担心被怀疑在摸鱼,所以需要时刻注意着消息提示,看到消息提示得马上回复;因为大家没办法面多面交流,为了快速达成一致就需要大家频繁地开会,我一个普通员工都开会开到难受,老板应该更难受,全天会议轰炸。

不过疫情对我来说也有好的一面,因为我本身就是一个不是很喜欢社交的人,因为疫情的原因,控制大家都不要出门,对于我来说这是一个很好的宅着的理由。让我有更多的时间可以跟自己独处,可以剖析我自己。遇到剖析不了的时候,那就去读书,去锻炼。起码这两件事不会有什么错误。

10 月份终于从上海回到了杭州工作,回来之后还是觉得在杭州比较自在,毕竟生活了二十几年,待久了想离开,离开了又想回来的地方。即使杭州这个地方变得让我有很多不喜欢的,但是回来的感觉真好。

人生中的第一本书也终于出来了,也算是了了自己一个小小的心愿,很长一段时间应该都不会想写第二本书了,从写第一本书的经历来看,自己肚子里还是很缺乏内容,我理解的写书是一种由内而外溢出来,自然而然的一个写作过程,而不是一种类似于学习的方式。我更希望我写的书是对读者能有用的,而不是多作者是有用的。所以我想着还是再积累积累,有生之年也不知道会不会有让我觉得有冲动可以写一本书的时候了。觉得缺少了很多输出的锻炼,无论是对技术文章还是其他的内容,总觉得没有感觉,准备写的时候,脑袋里空空的,啥也没有。所以 2021 年想让自己多输出、输出。

回顾

19 年立了不少 flag ,虽然倒了不少,但是该有的复盘还是得要有的:

  1. 25 本书:完成,数量上虽然完成了,但是质量上感觉没上去,读书一直感觉没有读进去,只是从我脑子中过了一年
  2. 坚持锻炼,体重减到 140:未完成,年年 flag 140 ,年年完成不了,距离目标最近的时候是 73.2KG ,那是 12 月份跟人打赌,一个月减掉了 10 斤,但是后续没有坚持减下去,不过有养成锻炼的习惯了,健身房离公司近,离家近真的很重要
  3. 找个女朋友,从“想”变成实际行动:未完成,的确还停留在 “找个女朋友的实际行动中”
  4. 做一个开源项目或者成为 TiDB Contributer:完成,零零总总也差不多给 tidb 提了 10 来个 PR 今年,虽然对 tidb 还是不了解,但是有些事情做了会发现没有想象中的那么难,干就完了
  5. 多赚钱:完成,这个没有具体标准来衡量,但是今年新增了一种投资手段——股票,幸运的是今年的股市行情还不错,所以第一年入股市没有亏,还赚了点。但是本身没有完备的投资体系,所以赚钱还都是因为运气,需要学习加总结。真想一夜暴富
  6. 发展一项爱好:算完成吧,今年尝试了一些消磨时间的爱好,比如拼模型、乐高、摩托车(考驾照中)、画画,虽然没有发现真正的爱好,但是做了不少尝试。

回顾下来,flag 的确倒了不少,但还好是完成的多于未完成的。2021 再接再厉。

立目标

21 年不立目标,低头做事。

环境介绍

  • TiDB 版本:v4.0.0
  • HAProxy 版本:1.5.18
  • IP 信息:
    • tidb-server IP: 172.16.5.189:14000
    • HAProxy IP: 172.16.5.171:12345
    • mysql client IP:172.16.5.169

配置步骤

配置 HAProxy 透传 IP ,主要是需要在 haproxy 配置文件中配置 send-proxy 选项,以及设置 tidb 配置 proxy-protocol.networks 为 HAProxy 所在机器IP

  • 查看集群信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[tidb@node5169 gangshen]$ tiup cluster display sg-latest
Starting component `cluster`: /home/tidb/.tiup/components/cluster/v1.0.7/tiup-cluster display sg-latest
TiDB Cluster: sg-latest
TiDB Version: v4.0.0
ID Role Host Ports OS/Arch Status Data Dir Deploy Dir
-- ---- ---- ----- ------- ------ -------- ----------
172.16.5.189:13000 grafana 172.16.5.189 13000 linux/x86_64 Up - /home/tidb/gangshen/install/deploy/grafana-13000
172.16.4.235:12379 pd 172.16.4.235 12379/12380 linux/x86_64 Up|L /home/tidb/gangshen/install/data/pd-12379 /home/tidb/gangshen/install/deploy/pd-12379
172.16.4.237:12379 pd 172.16.4.237 12379/12380 linux/x86_64 Up|UI /home/tidb/gangshen/install/data/pd-12379 /home/tidb/gangshen/install/deploy/pd-12379
172.16.5.189:12379 pd 172.16.5.189 12379/12380 linux/x86_64 Up /home/tidb/gangshen/install/data/pd-12379 /home/tidb/gangshen/install/deploy/pd-12379
172.16.5.189:19090 prometheus 172.16.5.189 19090 linux/x86_64 Up /home/tidb/gangshen/install/data/prometheus-19090 /home/tidb/gangshen/install/deploy/prometheus-19090
172.16.5.189:14000 tidb 172.16.5.189 14000/20080 linux/x86_64 Up - /home/tidb/gangshen/install/deploy/tidb-14000
172.16.5.171:30160 tikv 172.16.5.171 30160/30180 linux/x86_64 Up /home/tidb/gangshen/install/data/tikv-30160 /home/tidb/gangshen/install/deploy/tikv-30160
172.16.5.172:30160 tikv 172.16.5.172 30160/30180 linux/x86_64 Up /home/tidb/gangshen/install/data/tikv-30160 /home/tidb/gangshen/install/deploy/tikv-30160
  • 修改 tidb 配置 proxy-protocol.networks 为 HAProxy 所在机器IP并 reload 重启生效
1
2
tiup cluster edit-config sg-latest
tiup cluster reload sg-latest -R tidb

  • 修改 haproxy 配置,在 backend server 配置中添加 send-proxy 选项

具体 haproxy 安装以及配置可以参考 TiDB 官网 HAProxy 在 TiDB 中的最佳实践

  • 修改 haproxy 配置之后,重启 haproxy 生效配置

验证

在 172.16.5.169 机器上用 mysql client 连接 haproxy 并通过 show processlist 查看连接来源 IP

常见问题

连接报 ERROR 2013 (HY000): Lost connection to MySQL server at 'reading initial communication packet', system error: 0

问题现象:

问题原因:
haproxy 配置中没有配置 send-proxy 选项,修改 haproxy 配置之后正常。

环境信息及故障现象

集群版本

v4.0.0

故障现象

TiDB 集群的物理机异常断电重启,机器恢复后,通过 tiup cluster start <cluster-name> 启动集群,发现有两个 PD 节点启动失败

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
[root@localhost ~]# tiup cluster start t11
Starting component `cluster`: /root/.tiup/components/cluster/v1.0.0/cluster start t11
Starting cluster t11...
+ [ Serial ] - SSHKeySet: privateKey=/root/.tiup/storage/cluster/clusters/t11/ssh/id_rsa, publicKey=/root/.tiup/storage/cluster/clusters/t11/ssh/id_rsa.pub
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.128
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.151
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.152
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.153
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.151
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.152
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.155
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.128
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.128
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.154
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.153
+ [Parallel] - UserSSH: user=tidb, host=192.168.73.128
+ [ Serial ] - ClusterOperate: operation=StartOperation, options={Roles:[] Nodes:[] Force:false SSHTimeout:5 OptTimeout:60 APITimeout:300}
Starting component pd
Starting instance pd 192.168.73.153:2379
Starting instance pd 192.168.73.151:2379
Starting instance pd 192.168.73.152:2379
Start pd 192.168.73.151:2379 success
retry error: operation timed out after 1m0s
pd 192.168.73.153:2379 failed to start: timed out waiting for port 2379 to be started after 1m0s, please check the log of the instance
retry error: operation timed out after 1m0s
pd 192.168.73.152:2379 failed to start: timed out waiting for port 2379 to be started after 1m0s, please check the log of the instance

Error: failed to start: failed to start pd: pd 192.168.73.153:2379 failed to start: timed out waiting for port 2379 to be started after 1m0s, please check the log of the instance: timed out waiting for port 2379 to be started after 1m0s

Verbose debug logs has been written to /root/logs/tiup-cluster-debug-2020-06-16-16-28-39.log.
Error: run `/root/.tiup/components/cluster/v1.0.0/cluster` (wd:/root/.tiup/data/S23ruRB) failed: exit status 1

登陆到 PD 节点所在机器,查看 pd-server 进程的确不存在,通过 pd.log 日志查看 PD 启动失败的原因:

1
2
3
4
5
6
[2020/06/16 16:29:10.180 +08:00] [INFO] [systime_mon.go:26] ["start system time monitor"]
[2020/06/16 16:29:10.181 +08:00] [INFO] [backend.go:79] ["opened backend db"] [path=/tidb/tidb-data/pd-2379/member/snap/db] [took=739.112µs]
[2020/06/16 16:29:10.182 +08:00] [INFO] [server.go:443] ["recovered v2 store from snapshot"] [snapshot-index=1400015] [snapshot-size="22 kB"]
[2020/06/16 16:29:10.195 +08:00] [WARN] [db.go:92] ["failed to find [SNAPSHOT-INDEX].snap.db"] [snapshot-index=1400015] [snapshot-file-path=/tidb/tidb-data/pd-2379/member/snap/0000000000155ccf.snap.db] [error="snap: snapshot file doesn't exist"]
[2020/06/16 16:29:10.195 +08:00] [PANIC] [server.go:454] ["failed to recover v3 backend from snapshot"] [error="failed to find database snapshot file (snap: snapshot file doesn't exist)"]
[2020/06/16 16:29:10.195 +08:00] [FATAL] [log.go:292] [panic] [recover="\"invalid memory address or nil pointer dereference\""] [stack="github.com/pingcap/log.Fatal\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/pkg/mod/github.com/pingcap/log@v0.0.0-20200117041106-d28c14d3b1cd/global.go:59\ngithub.com/pingcap/pd/v4/pkg/logutil.LogPanic\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/src/github.com/pingcap/pd/pkg/logutil/log.go:292\nruntime.gopanic\n\t/usr/local/go/src/runtime/panic.go:679\nruntime.panicmem\n\t/usr/local/go/src/runtime/panic.go:199\nruntime.sigpanic\n\t/usr/local/go/src/runtime/signal_unix.go:394\ngo.etcd.io/etcd/etcdserver.NewServer.func1\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/pkg/mod/go.etcd.io/etcd@v0.5.0-alpha.5.0.20191023171146-3cf2f69b5738/etcdserver/server.go:335\nruntime.gopanic\n\t/usr/local/go/src/runtime/panic.go:679\ngo.uber.org/zap/zapcore.(*CheckedEntry).Write\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/pkg/mod/go.uber.org/zap@v1.13.0/zapcore/entry.go:230\ngo.uber.org/zap.(*Logger).Panic\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/pkg/mod/go.uber.org/zap@v1.13.0/logger.go:225\ngo.etcd.io/etcd/etcdserver.NewServer\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/pkg/mod/go.etcd.io/etcd@v0.5.0-alpha.5.0.20191023171146-3cf2f69b5738/etcdserver/server.go:454\ngo.etcd.io/etcd/embed.StartEtcd\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/pkg/mod/go.etcd.io/etcd@v0.5.0-alpha.5.0.20191023171146-3cf2f69b5738/embed/etcd.go:211\ngithub.com/pingcap/pd/v4/server.(*Server).startEtcd\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/src/github.com/pingcap/pd/server/server.go:257\ngithub.com/pingcap/pd/v4/server.(*Server).Run\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/src/github.com/pingcap/pd/server/server.go:441\nmain.main\n\t/home/jenkins/agent/workspace/build_pd_multi_branch_v4.0.0/go/src/github.com/pingcap/pd/cmd/pd-server/main.go:118\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:203"]

看到两个 PD 节点都报 failed to recover v3 backedn from snapshot 错误。

故障原因分析

故障解决步骤

参考官方文档 PD Recover 使用文档 ,通过 pd-recovery 工具恢复集群。

根据文档内容主要分为 3 个步骤:1. 找到 cluster id 2. 找到当前最大 alloc id 3. 通过 pd-recovery 恢复 pd 集群

这边的话,都是通过 PD 日志来查找 cluster idalloc id

操作步骤

  1. 查找 cluster id
1
2
3
4
5
6
7
8
9
10
11
12
13
[root@localhost ~]# cat /tidb/tidb-bin/pd-2379/log/pd.log | grep "init cluster id"
[2020/05/06 23:37:02.121 +08:00] [INFO] [server.go:340] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/05/07 00:03:25.132 +08:00] [INFO] [server.go:340] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/05/07 11:45:39.338 +08:00] [INFO] [server.go:340] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/05/25 14:54:50.076 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/05/25 16:45:55.526 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/05/25 16:48:21.462 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/06/01 19:13:17.478 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/06/04 02:28:29.655 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/06/05 22:27:46.152 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/06/08 22:50:30.045 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/06/08 22:50:59.534 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
[2020/06/11 01:48:35.936 +08:00] [INFO] [server.go:336] ["init cluster id"] [cluster-id=6823755660393880966]
  1. 查找当前最大的 alloc id ,因为 pd-recovery 去恢复的时候需要指定一个比当前最大的 alloc id 更大的 alloc id,所以需要对所有的 pd 节点日志进行查找
1
2
3
4
5
6
7
8
9
10
11
12
pd-1
[root@localhost ~]# cat /tidb/tidb-bin/pd-2379/log/pd.log | grep "allocates"
[2020/05/06 23:37:04.752 +08:00] [INFO] [id.go:110] ["idAllocator allocates a new id"] [alloc-id=1000]
[2020/05/12 11:21:28.271 +08:00] [INFO] [id.go:110] ["idAllocator allocates a new id"] [alloc-id=2000]

pd-2
[root@localhost ~]# cat /tidb/tidb-bin/pd-2379/log/pd.log | grep "allocates"

pd-3
[root@localhost ~]# cat /tidb/tidb-bin/pd-2379/log/pd.log | grep "allocates"
[2020/05/27 11:20:20.687 +08:00] [INFO] [id.go:110] ["idAllocator allocates a new id"] [alloc-id=3000]
[2020/06/10 18:04:43.361 +08:00] [INFO] [id.go:110] ["idAllocator allocates a new id"] [alloc-id=4000]
  1. 删除旧 PD 集群 data 目录下的所有内容,因为这个集群 PD 节点 data 目录为 /tidb/tidb-data/pd-2379 ,所以删除 /tidb/tidb-data/pd-2379 目录下所有内容
1
2
3
4
5
6
7
8
9
10
11
查看 data 目录
[root@localhost ~]# tiup cluster display t11
......
192.168.73.151:2379 pd 192.168.73.151 2379/2380 linux/x86_64 Healthy /tidb/tidb-data/pd-2379 /tidb/tidb-bin/pd-2379
192.168.73.152:2379 pd 192.168.73.152 2379/2380 linux/x86_64 Healthy /tidb/tidb-data/pd-2379 /tidb/tidb-bin/pd-2379
192.168.73.153:2379 pd 192.168.73.153 2379/2380 linux/x86_64 Healthy|L /tidb/tidb-data/pd-2379 /tidb/tidb-bin/pd-2379
......

删除数据目录

[root@localhost ~]# mv /tidb/tidb-data/pd-2379/* /tmp
  1. 启动 PD 集群
1
tiup cluster start t11 -R pd
  1. 通过 pd-recovery 恢复集群,--endpoints 指定一个 pd 节点,-cluster-id 指定查找到的 cluster-id ,-alloc-id 指定比查找到的最大 alloc id 更大的一个数字,所以这边只要指定一个比 4000 更大的数字即可
1
2
[root@localhost ~]# ./pd-recover -endpoints http://192.168.73.151:2379 -cluster-id 6823755660393880966 -alloc-id 10000
recover success! please restart the PD cluster
  1. 重启 PD 集群
1
tiup cluster restart t11 -R pd
  1. 启动集群
1
tiup cluster start t11
  1. 查看集群状态,恢复正常
1
tiup cluster display t11

注意

通过 tiup 部署的 TiDB 集群需要用户自己下载 pd-recovery 工具,可以通过 http://download.pingcap.org/tidb--linux-amd64.tar.gz 链接进行下载

Go 实现 gRPC 服务

介绍

最近学习了一下 gRPC 在 Go 中的实现,做一些记录。

gRPC 是什么

讲 gRPC 之前,可以先讲一下 RPC(Remote Procedure Call) 远程过程调用,它能让客户端直接类似于调用本地方法一样调用服务端的方法。gRPC 是 Google 开源的一款高性能 RPC 框架。

gRPC 中主要有四种请求/响应模式:

  • 普通 RPC
  • 服务端流式 RPC
  • 客户端流式 RPC
  • 服务端/客户端双向流式 RPC

gRPC 好处

RPC 一般是建立在 TCP 或者 HTTP 网络连接之上的,是一个框架,所以对于开发者而言,如果使用 RPC 去实现服务端与客户端的通信,基本可以不考虑网络协议、连接等内容,专注与处理逻辑。

使用 gRPC 的话,可以将我们的服务定义在 .proto 文件中,然后在任何一种支持 gRPC 的语言中实现客户端和服务端,这可以让服务端和客户端运行在不同的环境中,另外使用 gRPC 还有其他的好处:高效序列化与反序列化、简单的 IDL 语言、方便接口更新

基础使用

已官方的 helloworld 作为例子进行讲解,示例

准备工作

  • Mac 安装 protoc 编译器
1
$ brew install protobuf
  • 安装 protoc 编译器插件
1
$ go get -u github.com/golang/protobuf/protoc-gen-go

定义服务

gRPC 是通过 Protocol Buffers 去定义 gRPC 相关的 service 服务以及请求和响应相关的类型,如果对 Protocol Buffer 不熟悉的,其实也没什么关系,直接看 protoc 文件也是比较容易看懂的。如果对 Protocol Buffer 想深入了解的 ,可以参考这个链接: Protocol Buffers

创建 helloworld.proto 文件,并填写以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
syntax = "proto3";
package protos;

// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}


// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

来解释一下上面的代码:

  • 第一行 syntax = "proto3" 表示目前使用的是 proto3 的语法
  • 通过 service 关键字定义了一个 Greeter 服务,且这个服务目前只有 SayHello 一个方法,SayHello 这个方法会接收一个 HelloRequest 类型的消息,并返回一个 HelloReply 类型的消息
  • 通过 message 关键字定义了 HelloRequest 类型消息的结构是什么样的,上述代码中,HelloRequest 消息结构只有一个字段,是 string 类型
  • 通过 message 关键字定义了 HelloReply 类型消息的结构是什么样的,上述代码中,HelloReply 消息结构只有一个字段,是 string 类型
  • 定义消息结构字段的时候,需要指定字段的字段编号,比如 string name = 1 中,1 就是字段编号,这个字段编号是一个唯一的数字,作用是在二进制消息体中表示字段用的
  • 定义消息结构字段时,需要制定更多数据类型的话,可以参考链接:Language Guide (proto3)

在 proto 文件中完成定义 gRPC 的服务方法和消息之后,我们就可以来生成 gRPC 通信中服务端与客户端的接口了,这个时候就需要我们之前安装的 protoc 以及 protoc-gen-go 工具了

1
2
3
4
5
$ protoc --go_out=plugins=grpc:. helloworld.proto

--go_out 表示指定最终生成文件的输出路径,. 表示生成在当前路径下

## 注意这边如果使用 protoc --go_out=grpc:. hellororld.proto 命令生成的结果和指定了 plugins 生成的结果有不同,需要加上 plugins 去生成

命令执行完成之后,会生成 helloworld.pd.go,具体的内容因为篇幅原因,不完全展示了,可以参考:helloworld.pd.go

会根据 proto 文件中定义的服务方法和消息生成对应的代码,比如:

  • 是消息的话会对应生成
    • 对应的 struct 结构体
    • Reset()/String()/ProtoMessage()/Descriptor()/XXX_Unmarshal()/XXX_Marshal()/XXX_Merge()/XXX_Merge()/XXX_DiscardUnknown() 方法
    • 消息中定义字段的 get 方法
  • 是服务的话会对应生成
    • 生成服务对应的 server 以及 client 接口
    • 接口对应的方法

编写服务端和客户端代码

gRPC 服务方法和消息定义完成,对应的服务端和客户端接口也定义完成之后,就需要来写服务端和客户端的具体实现代码了

服务端代码

这部分代码也是 grpc-go 官方 helloworld example 例子中的代码,借这个简单的代码理解一下服务端代码的编写

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 (
"context"
"log"
"net"
pb "github.com/Win-Man/sg-server/protos"
"google.golang.org/grpc"
)

const (
port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", in.GetName())
return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}

// 创建一个 gRPC 服务端实例
s := grpc.NewServer()
// 注册
pb.RegisterGreeterServer(s, &server{})
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

解释一下上面的代码

  • 首先定义了一个 server 的结构体,这个没有指定字段,直接继承了生成的 helloworld.pb.go 中定义的 pb.UnimplementedGreeterServer 结构体
  • 实现了 server 结构体的 SayHello 方法,SayHello 方法接受两个参数(ctx context.Context,in pb.HelloRequest)返回两个参数(pb.HelloReply,error),HelloRequest 和 HelloReply 其实就是 proto 中定义的消息对象,SayHello 方法的具体实现就看具体需求了,在例子中的话,就是获取了发送过来的请求中的 name 字段,将获取到的 name 值拼接上 Hello 以 Message 返回给客户端
  • 在 main 函数中定义了服务端的启动和监听过程

客户端代码

这部分代码也是 grpc-go 官方 helloworld example 例子中的代码,借这个简单的代码理解一下客户端代码的编写

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 (
"context"
"log"
"os"
"time"
pb "github.com/Win-Man/sg-server/protos"
"google.golang.org/grpc"
)

const (
address = "localhost:50051"
defaultName = "sg"
)



func main() {
// 建立连接
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Fatalf("did not connect: %v", err)
}

defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response.
// 获取命令行中传入的第一个参数作为 name 值,否则name就使用默认值
name := defaultName
if len(os.Args) > 1 {
name = os.Args[1]
}
// 设置超时时间
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.GetMessage())
}

解释一下上面的代码:

  • 所有的逻辑都是在 main 函数中
  • conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock()) 这个是在建立客户端与服务端之间的 grpc 连接
  • c := pb.NewGreeterClient(conn) 通过连接初始化客户端
  • r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name}) 客户端通过调用 gRPC 方法与服务端通信,就收服务端返回的信息

运行演示

  • 运行服务端代码
1
2
3
$ go run server.go
2020/03/29 00:19:48 Received: sg
2020/03/29 00:22:11 Received: hhhh
  • 运行客户端代码
1
2
3
4
5
$ go run client.go
2020/03/29 00:19:48 Greeting: Hello sg

$ go run client.go hhhh
2020/03/29 00:22:11 Greeting: Hello hhhh

客户端成功通过 gRPC 调用了服务端。

进阶使用

简单使用中已经介绍了简单 RPC 的使用方式,在这一节中,会补充讲解 gRPC 其他几种请求/响应模式,标准步骤都是:

  1. 在 .proto 文件中定义 RPC 服务方法和消息
  2. 根据 .proto 文件生成 pb.go 文件
  3. 实现服务端代码
  4. 实现客户端代码

服务端流式 RPC

服务端流式 RPC 顾名思义就是服务端会按照多次返回响应,直到服务端认为响应发送完毕,会告诉客户端响应应发送完毕

  • 首先在 .proto 文件中定义服务端流式 RPC 的服务方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
service Greeter {
......
rpc TellMeSomething(HelloRequest) returns (stream Something){}
......
}



// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

......
message Something{
int64 lineCode = 1;
string line = 2;
}

在原先定义的 Greeter 服务的基础上添加了一个 TellMeSomething 的 RPC 方法,这个 RPC 方法接收的是请求结构是 HelloRequest 类型,返回的结构是 Something 类型,且用了 stream 定义,表示返回结构是一个流,Something 类型包含一个 int64 类型的 lineCode 字段和以一个 string 类型的 line 字段。

  • 根据 .proto 文件生成 pb.go 文件,因为是自动生成的,这边就不展示完全的 pb.go 文件内容
  • 编写服务端代码,在服务端实现 TellMeSomething 方法
1
2
3
4
5
6
7
8
9
10
11
12
func (s *server)TellMeSomething(in *pb.HelloRequest, stream pb.Greeter_TellMeSomethingServer) error{
log.Printf("Received ServerStream request from: %v", in.GetName())
// 获取客户端发送的请求内容
hellostr := fmt.Sprintf("Hello,%s",in.GetName())
something := []string{hellostr,"ServerLine1","ServerLine2","ServerLine3"}
for i,v := range(something){
if err := stream.Send(&pb.Something{LineCode:int64(i),Line:v});err != nil{
return err
}
}
return nil
}

TellMeSomething 方法接受两个参数,一个是 *pb.HelloRequest 类型的参数,表示客户端发送的请求,另一个是 pb.Greeter_TellMeSomethingServer 类型的参数,这个参数其实就是服务端返回给客户端的流对象接口,生成的 .pb.go 文件中已经帮我们定义好了这个接口,包含一个 Send() 方法,用于发送响应给客户端,还有就是继承成 grpc.ServerStream 的流对象

1
2
3
4
type Greeter_TellMeSomethingServer interface {
Send(*Something) error
grpc.ServerStream
}

在服务端通过 pb.Greeter_TellMeSomethingServer 对象的 Send() 方法给客户端发送响应,如果发送所有响应结束,则通过 return nil 的方式通知客户端响应发送结束,客户端会接收到 io.EOF 信号知道服务端已经发送完毕。如果发送中间过程有错误,则通过 return err 的方式将对应的错误通知给客户端

  • 编写客户端代码,在客户端调用 TellMeSomethinng 方法
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
func main() {
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response.
name := defaultName
clientFunc := "Default"
if len(os.Args) == 3 {
name = os.Args[1]
clientFunc = os.Args[2]
}else{
log.Fatal("Please input 2 arguments")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
switch clientFunc {
......
case "ServerStream":
stream,err := c.TellMeSomething(ctx,&pb.HelloRequest{Name:name})
if err != nil{
log.Fatalf("TellMeSomething error: %v ",err)
}
for {
something,err := stream.Recv()
// 服务端信息发送完成,退出
if err == io.EOF{
break
}
if err != nil{
log.Fatalf("TellMeSomething stream error:%v",err)
}
log.Printf("Recevie from server:{LineCode:%v Line:%s}\n",something.GetLineCode(),something.GetLine())
}
......
}

客户端调用 TellMeSomething 方法,发送了一个 HelloRequest 类型的请求,获得一个 stream 返回对象,通过调用 Recv() 方法客户端接收服务端发送的一次次响应内容

客户端流式 RPC

客户端流式 RPC 就是客户端不断发送请求,直到发送完毕之后通知服务端请求发送完毕

  • 首先在 .proto 文件中定义服务端流式 RPC 的服务方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
service Greeter {
......
rpc TellYouSomething(stream Something) returns(HelloReply){}
......
}
......
// The response message containing the greetings
message HelloReply {
string message = 1;
}

message Something{
int64 lineCode = 1;
string line = 2;
}

在原先定义的 Greeter 服务的基础上添加了一个TellYouSomething 的 RPC 方法,这个 RPC 方法接收的是请求结构是 Something 类型,且用了 stream 定义,表示接受的请求是一个流,Something 类型包含一个 int64 类型的 lineCode 字段和以一个 string 类型的 line 字段,服务端返回给客户端一个 HelloReply 类型的响应

  • 根据 .proto 文件生成 pb.go 文件,因为是自动生成的,这边就不展示完全的 pb.go 文件内容
  • 编写服务端代码,在服务端实现 TellYouSomething 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (s *server)TellYouSomething(stream pb.Greeter_TellYouSomethingServer) error{
log.Printf("Recevied ClientStream request")
messgeCount := 0
length := 0
for {
something,err := stream.Recv()
// 接收到客户端所有请求,返回响应结果
if err == io.EOF{
return stream.SendAndClose(&pb.HelloReply{Message:fmt.Sprintf("It's over.\nReceived %v times. Length:%v",messgeCount,length)})
}
if err != nil{
return err
}
messgeCount ++
length += len(something.GetLine())
}
return nil
}

这个与服务端流式 RPC 中客户端的代码比较类似,服务端通过调用 stream 的 Recv() 方法获取客户端发送的请求,直到接受到 io.EOF 信号表示已经接收到全部客户端的请求,可以返回响应了,如果中间接收请求出错,则将错误直接返回给客户端

  • 编写客户端代码,在客户端调用 TellYouSomething 方法
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
func main() {
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response.
name := defaultName
clientFunc := "Default"
if len(os.Args) == 3 {
name = os.Args[1]
clientFunc = os.Args[2]
}else{
log.Fatal("Please input 2 arguments")
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
switch clientFunc {
......
case "ClientStream":
stream,err := c.TellYouSomething(ctx)
if err != nil{
log.Fatalf("TellYouSomething err :%v",err)
}
clientStr := []string{"ClientLine1","ClientLine2"}
for i,v := range(clientStr){
if err := stream.Send(&pb.Something{LineCode:int64(i),Line:v});err != nil{
log.Fatalf("TellYouSomething stream error:%v",err)
}
}
rep,err := stream.CloseAndRecv()
if err != nil{
log.Fatalf("CloseAndRecd error:%v",err)
}
log.Printf("Recive from server:%s",rep.GetMessage())
......
}

与服务端流式 RPC 中服务端的代码类似,通过调用 stream 的 Send() 方法给客户端发送请求,等所有请求发送完毕通过调用 CloseAndRevc() 方法通知服务端请求发送完毕,并接收服务端的响应。

服务端/客户端双向流式 RPC

服务端/客户端双向流式 RPC 即客户端和服务端都是流式方式发送请求的

  • 首先在 .proto 文件中定义服务端流式 RPC 的服务方法
1
2
3
4
5
6
7
8
9
10
service Greeter {
......
rpc TalkWithMe(stream Something) returns(stream Something){}
}

......
message Something{
int64 lineCode = 1;
string line = 2;
}

在原先定义的 Greeter 服务的基础上添加了一个TalkWithMe的 RPC 方法,这个 RPC 方法接收的是请求结构是 Something 类型,且用了 stream 定义,表示接受的请求是一个流,Something 类型包含一个 int64 类型的 lineCode 字段和以一个 string 类型的 line 字段,服务端返回给客户端的也是 Something 类型的 stream 流。

  • 根据 .proto 文件生成 pb.go 文件,因为是自动生成的,这边就不展示完全的 pb.go 文件内容
  • 编写服务端代码,在服务端实现 TalkWithMe 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (s *server)TalkWithMe(stream pb.Greeter_TalkWithMeServer) error{
log.Printf("Recevied Stream request")
messageCount := 0
for {
something ,err := stream.Recv()
if err == io.EOF{
return nil
}
messageCount ++
length := len(something.GetLine())
line := fmt.Sprintf("Got %s,Length:%v",something.GetLine(),length)
if err := stream.Send(&pb.Something{LineCode: int64(messageCount),Line:line});err != nil{
return err
}
}
return nil
}

服务端代码与客户端流式 RPC 中服务端代码几乎是一样的,所以就不作解释

  • 编写客户端代码,调用 TalkWithMe 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
func main() {
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response.
name := defaultName
clientFunc := "Default"
if len(os.Args) == 3 {
name = os.Args[1]
clientFunc = os.Args[2]
}else{
log.Fatal("Please input 2 arguments")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
switch clientFunc {
......
case "Stream":
stream,err := c.TalkWithMe(ctx)
if err != nil{
log.Fatalf("TalkWithMe err:%v",err)
}
waitc := make(chan struct{})
go func(){
for {
something,err := stream.Recv()
// 服务端信息发送完成,退出
if err == io.EOF{
break
}
if err != nil{
log.Fatalf("TalkWithMe stream error:%v",err)
}
log.Printf("Got %v:%s\n",something.GetLineCode(),something.GetLine())
}
}()
clientStr := []string{"one","two","three"}
for i,v := range(clientStr){
if err := stream.Send(&pb.Something{LineCode:int64(i),Line:v});err != nil{
log.Fatalf("TalkWithMe Send error:%v",err)
}
}
stream.CloseSend()
<- waitc
default:
log.Fatal("Please input second args in Default/ServerStream/ClientStream/Stream")
}

}

客户端发送请求还是通过调用 stream 的 Send() 方法发送请求,但是通知服务端请求发送完毕是通过调用 CloseSend() 方法,不过因为服务端发送的请求不是一次性发送的,所以这边用 goroutine 新开了一个线程用于接收服务端返回的响应。

演示

  • 服务端流式 RPC
1
2
3
4
5
6
7
8
9
10
11

## 终端 1
$ go run server.go
2020/03/29 19:50:47 Received ServerStream request from: Aob

## 终端 2
$ go run client.go Aob ServerStream
2020/03/29 19:50:47 Recevie from server:{LineCode:0 Line:Hello,Aob}
2020/03/29 19:50:47 Recevie from server:{LineCode:1 Line:ServerLine1}
2020/03/29 19:50:47 Recevie from server:{LineCode:2 Line:ServerLine2}
2020/03/29 19:50:47 Recevie from server:{LineCode:3 Line:ServerLine3}
  • 客户端流式 RPC
1
2
3
4
5
6
7
8
## 终端 1 
$ go run server.go
2020/03/29 19:51:47 Recevied ClientStream request

## 终端 2
$ go run client.go Aob ClientStream
2020/03/29 19:51:47 Recive from server:It's over.
Received 2 times. Length:22
  • 服务端/客户端流式 RPC
1
2
3
4
5
6
7
8
9
## 终端 1 
$ go run server.go
2020/03/29 19:52:46 Recevied Stream request

## 终端 2
$ go run client.go Aob Stream
2020/03/29 19:52:46 Got 1:Got one,Length:3
2020/03/29 19:52:46 Got 2:Got two,Length:3
2020/03/29 19:52:46 Got 3:Got three,Length:5

总结

以上就是通过 Go 学习 gRPC 的一些记录,只是简单跑通了服务端与客户端之间的请求。完整代码地址

一开始刚开始看 gRPC 的时候其实有点晕,因为又是 gRPC 又是 protocol 又是流式 RPC 的,但是实际学习下来发现定义简单的 gRPC 服务还是比较简单的,按照步骤走就可以:

  1. 在 .proto 文件中定义 RPC 服务方法和消息
  2. 根据 .proto 文件生成 pb.go 文件
  3. 实现服务端代码
  4. 实现客户端代码

下一步准备学习一下 gRPC 与 TLS 安全加密相关的内容

docker-compose 中 network_mode 设置导致无法从容器外部访问 MySQL

问题现象

通过 docker-compose 在自己电脑上部署 MySQL,启动之后,查看容器状态正常,进入容器内部访问 MySQL 正常,但是在宿主机上连接 MySQL 失败。

  • docker-compose.yml 配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ cat docker-compose.yml
version: '2'
services:
mysql:
network_mode: "host"
environment:
MYSQL_ROOT_PASSWORD: "xxxx"
MYSQL_USER: "qbench"
MYSQL_PASS: "xxxx"
image: "docker.io/mysql:5.7.22"
ports:
- "3306:3306"
restart: always
volumes:
- "./db:/var/lib/mysql"
- "./conf/my.cnf:/etc/my,cnf"
- "./init:/docker-entrypoint-initdb.d/"
  • 拉取镜像
1
$ docker-compose -f docker-compose.yml pull
  • 启动
1
$ docker-compost -f docker-compose.yml up -d
  • 从宿主机上连接 MySQL 报错
1
2
3
4
5
6
7
8
[20-02-03 19:46:34]  shengang@abcs-MacBook-Pro  ~/Documents/002-workspace/docker-workspace/mysql-5.7.22
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4865e2c56f67 mysql:5.7.22 "docker-entrypoint.s…" 6 seconds ago Up 5 seconds mysql-5722_mysql_1
[20-02-03 19:46:39] shengang@abcs-MacBook-Pro ~/Documents/002-workspace/docker-workspace/mysql-5.7.22
$ mysql -uroot -P3306 -h127.0.0.1 -p
Enter password:
ERROR 2003 (HY000): Can't connect to MySQL server on '127.0.0.1' (61)
  • 但是进入容器内部连接 MySQL 正常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[20-02-03 19:46:56]  shengang@abcs-MacBook-Pro  ~/Documents/002-workspace/docker-workspace/mysql-5.7.22
$ docker exec -it 4865e2c56f67 bash
root@linuxkit-025000000001:/# mysql -uroot -P3306 -h127.0.0.1 -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.7.22 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

问题排查

排查过程其实比较简单,因为从容器内部连接正常,从宿主机连接失败,这个基本上就是网络或者端口或者防火墙的问题了。首先看 docker container ls 的结果输出中 PORTS 列的内容是空的,说明端口没有映射出来。但是在 docker-compose.yml 配置文件中我们明明设置了 ports 配置。仔细看了下 docker-compose.yml 配置文件,发现一个 network_mode: "host" 的配置项,这个我之前没怎么了解过,只是抄的网上的配置文件。大概率就是这个配置引起的问题。于是尝试了一下将 network_mode: "host" 这一行删除掉了。然后通过 docker-compose -f docker-compose.yml down ,docker-compose -f docker-compose.yml up -d 重新部署容器,连接就正常了。

问题原因

虽然问题解决了,但是最终还是需要了解一下是为什么会导致问题的出现,得好好理解一下 network_mode 这个配置的含义。

docker-compose.yml 配置文件中的 netwokr_mode 是用于设置网络模式的,与 docker run 中的 –network 选项参数一样,加上了 service:[service name] 形式的配置,默认是 bridge 桥接模式

  • network_mode: “bridge”

默认的网络模式。如果没有指定网络驱动,默认会创建一个 bridge 类型的网络。桥接模式一般是用在应用是独立的情况,容器之间不需要互相通信。

  • network_mode: “host”

host 网络模式,针对的也是应用是独立的情况,在 host 网络模式下,移除了宿主机与容器之间的网络隔离,荣容器直接使用宿主机的网络,这样就能在容器中访问宿主机网络。host 网络模式只对 Docker 17.06 以及更高版本的 swarm 服务可用。

  • network_mode: “none”

none 表示对于这个 container ,禁用所有的网络。

  • network_mode: “service:[service name]”

  • network_mode: “container:[container name/id]”

当在 swarm 服务中,network_mode 选项会被忽略。

使用 gomail 发送邮件

使用 gomail 的一些记录笔记。

准备工作

  • 准备 SMTP/IMAP 服务用于代发邮件(这部分内容不赘述,网上可以搜索关键字:QQ/163/gamil SMTP即可)
  • 写邮件需要填写哪些信息
    • From/发件人(必须)
    • To/收件人(必须)
    • Cc/抄送(可选)
    • Subject/标题(必须)
    • Body/邮件正文内容(必须)
    • Attach/附件(可选)

使用 gomail

  • 下载 gomail 包
1
go get gopkg.in/gomail.v2
  • 创建一个要发送邮件的对象
1
m := gomail.NewMessage()

gomail 中使用 NewMessage 方法创建一个新的消息对象,默认是使用 UTF-8 字符集的,在这个创建的消息对象上设置发件人、收件人等邮件的信息,所以下面填写邮件信息的操作都是基于 NewMessage 方法创建的对象基础上的。

  • 填写 From/发件人
1
m.SetHeader("From","from@xx.com")

gomail 中通过 func (m *Message) SetHeader(field string, value ...string) 方法设置收件人、发件人、标题等信息,SetHeader 方法第一个参数表示设定要设置的内容,From 表示发件人,To 表示收件人,Subject 表示标题,后面可以跟多个 String 类型的参数,可以表示多个收件人

  • 填写 To/收件人
1
m.SetHeader("To","to@xx.com","to_1@xx.com")
  • 填写 Cc/抄送人
1
m.SetAddressHeader("Cc","cc@xx.com","cc")

gomail 中通过 func (m *Message) SetAddressHeader(field, address, name string) 方法设置抄送人,SetAddressHeader 方法第一个参数表示设定要设置的内容,第二个参数表示抄送人的邮箱地址,第三个参数表示抄送人名称

  • 填写 Subject/标题
1
m.SetHeader("Subject","This is mail title")
  • 填写 Body/邮件正文内容
1
m.SetBody("text/html","Mr.Li<br>Thanks For your help.")

gomail 中通过 func (m *Message) SetBody(contentType, body string, settings ...PartSetting) 方法设置邮件正文内容, SetBody 方法第一个参数表示设置文本内容类型,可以是 text/plain 也可以是 text/html,第二个参数表示正文内容字符串,第三个参数表示一些额外的设置(大部分情况可以不设置),如果有字符 encode 的需要可以设置

  • 添加附件
1
m.Attach("../../go.mod")

gomail 中通过 func (m *Message) Attach(filename string, settings ...FileSetting) 方法添加邮件附件,Attach 方法第一个参数表示附件文件路径,第二个参数表示表示一些额外的设置,比如附件文件名称修改等(可以不设置)

  • 设置 SMTP/IMAP 服务器
1
d := gomail.NewDialer("smtp.qq.com",465, "1234567890@qq.com", "xxxxxxxx")

gomail 中通过 func NewDialer(host string, port int, username, password string) *Dialer 方法创建一个 SMTP Dialer 用于发送邮件

  • 发送邮件
1
2
3
4
err := d.DialAndSend(m)
if err != nil{
panic(err)
}

gomail 中通过 func (d *Dialer) DialAndSend(m ...*Message) error 方法连接到 SMTP 服务器并发送邮件,最终关闭连接。

  • 使用 SetHeaders 方法一起设置邮件内容

前面介绍设置邮件发件人、收件人、标题等信息都是通过 SetHeader 方法,调用 SetHeader 方法只能设置一项内容,如果想要同时设置多项,那可以考虑使用 SetHeaders 方法,传入一个 map[string][]string 类型的参数即可

1
2
3
4
5
6
mailHeader := map[string][]string{
"From": {"from@qq.com"},
"To": {"to_user1@qq.com", "to_user2@gmail.com"},
"Subject": {"标题"},
}
m.SetHeaders(mailHeader)

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import (
"gopkg.in/gomail.v2"
)

func BaseSend() {
mailHeader := map[string][]string{
"From": {"from@qq.com"},
"To": {"to_user1@qq.com", "to_user2@gmail.com"},
"Subject": {"标题"},
}

m := gomail.NewMessage()
m.SetHeaders(mailHeader)
m.SetAddressHeader("Cc", "shengang@pingcap.com", "shengang")
m.SetBody("text/html", "尊敬的客户")
m.Attach("../../go.mod")

d := gomail.NewDialer("smtp.qq.com", 465, "1234567890@qq.com", "xxxxxxxx")

if err := d.DialAndSend(m); err != nil {
panic(err)
}
}

参考链接