一. Go语言初识

1.1 什么是Go语言

特点:

  1. 高性能、高并发
  2. 语法简单
  3. 丰富的标准库
  4. 完善的工具链
  5. 静态链接
  6. 快速编译
  7. 跨平台
  8. 垃圾回收

简单的文件服务器:

1
2
3
4
5
6
7
8
9
10
package main

import (
"net/http"
)

func main(){
http.Handle("/", http.FileServer(http.Dir(".")))
http.ListenAndServe(":8080", nil)
}

1.2 配置开发环境

安装Goland,安装完成后在终端查看版本号:

1
2
(base) ➜  ~ go version
go version go1.19 darwin/arm64

1.3 基础语法

1.3.1 Hello World

通过package main,导入fmt包,使用其Print函数输出:

1
2
3
4
5
6
7
8
9
package main

import (
"fmt"
)

func main() {
fmt.Print("hello world")
}

1.3.2 变量类型

Go与Java类似,是一门强变量类型语言。

字符串、整数、浮点数、布尔型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
var a = "test" // 字符串
var b, c int = 1, 2 // int
var d = false
var e float64
// := 运算符可以使变量在不声明的情况下直接被赋值使用
f := float32(e)
g := a + "go"
fmt.Println(a, b, c, d, e, f) // test 1 2 false 0 0
fmt.Println(g) // testgo

const s string = "constant"
const h = 5000 // 根据上下文自动确定类型
const i = 3e20
fmt.Println(s, h, i, math.Sin(h), math.Cos(i))
}

1.3.2 if-else

与C++类似,显著区别在于Go里的判断条件没有括号

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

import "fmt"

func main() {
if 7%2 == 0 {
fmt.Println("7 is even")
} else {
fmt.Println("7 is odd")
}

if num := 9; num < 0 {
fmt.Println("negative")
} else if num < 10 {
fmt.Println(num, "has 1 digit")
} else {
fmt.Println(num, "has multiple digits")
}
}

运行结果:

7 is odd
9 has 1 digit

1.3.3 循环

与C++类似,可以使用continue、break等关键字

for循环:

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

import "fmt"

func main() {
i := 1
for {
fmt.Println("loop")
break
}
for i <= 4 {
fmt.Println(i)
i++
}
for j := 7; j < 9; j++ {
fmt.Println(j)
}
}

switch循环(与C++不同,不需要手动在条件里break):

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

import (
"fmt"
"time"
)

func main() {
a := 2
switch a {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
case 3, 4:
fmt.Println("three or four")
default:
fmt.Println("other")
}

t := time.Now()
switch {
case t.Hour() < 12:
fmt.Println("before noon")
default:
fmt.Println("after noon")
}
}

1.3.4 数组

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

import "fmt"

func main() {
var a [5]int
a[4] = 100
fmt.Println(a[4], len(a))

b := [5]int{1, 2, 3, 4, 5}
fmt.Println(b)

var twoD [2][3]int
for i := 0; i < 2; i++ {
for j := 0; j < 3; j++ {
twoD[i][j] = i + j
}
}
fmt.Println(twoD)
}

1.3.5 切片

注意开闭区间:

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

import "fmt"

func main() {
s := make([]string, 3)
s[0] = "a"
s[1] = "b"
s[2] = "c"
fmt.Println("get:", s[2])
fmt.Println("len", len(s))

s = append(s, "d")
s = append(s, "e", "f")
fmt.Println(s) // [a b c d e f]

c := make([]string, len(s))
copy(c, s)
fmt.Println(c)

fmt.Println(s[2:5]) // 下标区间[2,5) -> [c d e]
fmt.Println(s[:5])
fmt.Println(s[2:])

good := []string{"abc", "def", "ghi"}
fmt.Println(good)
}

1.3.6 map

写法与Java差异较大,可以使用make创建空map,也可以直接用花括号填入值:

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

import "fmt"

func main() {
m := make(map[string]int)
m["one"] = 1
m["two"] = 2
fmt.Println(m)
fmt.Println(len(m))
fmt.Println(m["one"])

r, ok := m["unknown"] // 是否存在
fmt.Println(r, ok) // 0 false

delete(m, "one")

m2 := map[string]int{"one": 1, "two": 2}
fmt.Println(m2)
}

1.3.7 range

可以用来简化遍历的循环代码,例如遍历数组或者map:

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 "fmt"

func main() {
nums := []int{1, 2, 3, 4}
sum := 0
for i, num := range nums {
sum += num
if num == 2 {
fmt.Println("index:", i, "num:", num)
}
}
fmt.Println(sum)

m := map[string]string{"a": "A", "b": "B"}
for key, val := range m {
fmt.Println(key, val)
}

for key := range m {
fmt.Println(key)
}
}

1.3.8 函数

与Java或C++不同,Go的函数可以存在多个返回值:

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

import "fmt"

func add(a int, b int) int {
return a + b
}

func exists(m map[string]string, k string) (v string, ok bool) {
v, ok = m[k]
return v, ok
}

func main() {
res := add(5, 9)
fmt.Println(res)

v, ok := exists(map[string]string{"a": "A"}, "a")
fmt.Println(v, ok) // a true
}

1.3.9 指针

与C++相比,Go指针功能有限,下面以add函数的值传递为例:

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

import "fmt"

func add(n int) {
n += 2 // 无效,因为n的值是拷贝
}

func add2ptr(n *int) {
*n += 2
}

func main() {
n := 23
add(n)
fmt.Println(n) // 23
add2ptr(&n)
fmt.Println(n) //25
}

1.3.10 结构体

与C类似:

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

import "fmt"

type user struct {
name string
password string
}

func main() {
a := user{name: "zhang", password: "123456"}
b := user{name: "yan", password: "2048"}
fmt.Println(a.password)

var c user
c.name = "james"
c.password = "1111"

fmt.Println(a, b, c) // {zhang 123456} {yan 2048} {james 1111}
}

1.3.11 错误处理

不同于Java的异常处理,Go能返回具体哪个函数出现错误:

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

import (
"errors"
"fmt"
)

type user struct {
name string
password string
}

func findUser(users []user, name string) (v *user, err error) {
for _, u := range users { // _为占位符,本质上是返回key-value,把key忽略了
if u.name == name {
return &u, nil
}
}
return nil, errors.New("Not Found")
}

func main() {

if u, err := findUser([]user{{"james", "12345"}}, "james"); err != nil {
fmt.Println(err)
return
} else {
fmt.Println(u.name)
}
}

1.3.12 字符串操作

一些常用操作:

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

import (
"fmt"
"strings"
)

func main() {
a := "Hello"
fmt.Println(strings.Contains(a, "ll")) // true
fmt.Println(strings.Count(a, "l")) // 2
fmt.Println(strings.HasPrefix(a, "He")) // true
fmt.Println(strings.HasSuffix(a, "lo")) // true
fmt.Println(strings.Index(a, "el")) // 1
fmt.Println(strings.Join([]string{"He", "llo"}, "-")) // He-llo
fmt.Println(strings.Repeat(a, 2)) // HelloHello
fmt.Println(strings.Replace(a, "e", "E", -1)) // HEllo
fmt.Println(strings.Split("a-b-c", "-")) // [a b c]
fmt.Println(strings.ToLower(a)) // hello
fmt.Println(strings.ToUpper(a)) // HELLO
}

格式化,用%v可以打印任何格式,%+v和%#v可以获取更详细格式:

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

import (
"fmt"
)

type point struct {
x, y int
}

func main() {
a := "Hello"
b := 123
p := point{1, 3}

fmt.Printf("a=%v\n", a)
fmt.Printf("b=%v\n", b)
fmt.Printf("p=%v\n", p) // p={1 3}
fmt.Printf("p=%+v\n", p) // p={x:1 y:3}
fmt.Printf("p=%#v\n", p) // p=main.point{x:1, y:3}
}

1.3.13 JSON处理

通过Marshal和UnMarshal方法序列化、反序列化JSON字符串:

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

import (
"encoding/json"
"fmt"
)

type userInfo struct {
Name string
Age int `json:"age"`
Hobby []string
}

func main() {
a := userInfo{Name: "james", Age: 18, Hobby: []string{"Go", "Java"}}
buf, err := json.Marshal(a) // 序列化,Unmarshal可以反序列化到userInfo中
if err != nil {
panic(err)
}
fmt.Println(string(buf)) // {"Name":"james","age":18,"Hobby":["Go","Java"]}

buff, err := json.MarshalIndent(a, "", "\t") // 更直观
if err != nil {
panic(err)
}
fmt.Println(string(buff))
}

1.3.14 时间处理

包括格式化时间,构造时间,获取时间等:

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

import (
"fmt"
"time"
)

func main() {
now := time.Now()
fmt.Println(now)
t := time.Date(2023, 5, 13, 12, 59,
35, 0, time.UTC)
fmt.Println(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute())
fmt.Println(t.Format("2006-01-02 15:04:05")) // 只能用北美山地时间(MST:Mountain Standard Time)2006年1月2日下午(PM)3点4分5秒这个时间

t2 := time.Date(2024, 5, 13, 12, 59,
35, 0, time.UTC)
fmt.Println(t2.Sub(t).Hours()) // 8784
}

1.3.15 数字解析

包括进制转换,字符串与数字相互转换等:

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

import (
"fmt"
"strconv"
)

func main() {
f, _ := strconv.ParseFloat("1.234", 64)
fmt.Println(f) // 1.234

n, _ := strconv.ParseInt("1000", 16, 64)
n2, _ := strconv.ParseInt("0x1000", 0, 64) // 第二个参数为0,通过0x 0前缀判断
fmt.Println(n, n2) // 4096 4096

t, _ := strconv.Atoi("12390")
fmt.Println(t) // 12390

g := strconv.Itoa(214)
fmt.Println(g + "s") // "214s"
}

1.3.16 进程信息

例子:

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

import (
"fmt"
"os"
"os/exec"
)

func main() {
fmt.Println(os.Args)
fmt.Println(os.Getenv("PATH"))

buf, err := exec.Command("grep", "127.0.0.1", "/etc/hosts").CombinedOutput()
if err != nil {
panic(err)
}
fmt.Println(string(buf))
}

1.3.17 小结

通过这一节课程,初识了Go基础语法,个人感觉Go有点像Java与Python的结合体,代码风格简洁度介于两者之间。有一些特性让我感觉编写代码更方便了,例如操作JSON序列化那一块,可以通过`符号定义序列化后key的值。

1.4 简易Demo上手

1.4.1 猜数字游戏

通过rand.Seed设置随机数种子,生成100以内的随机数,通过bufio和reader读取输入的字符串,通过atoi函数转换为数值,然后进行判断,比较简单。

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

import (
"bufio"
"fmt"
"math/rand"
"os"
"strconv"
"strings"
"time"
)

func main() {
maxNum := 100
rand.Seed(time.Now().UnixNano())
secretNumber := rand.Intn(maxNum)
fmt.Println(secretNumber)

fmt.Println("请输入你猜的数字:")
reader := bufio.NewReader(os.Stdin) // 输入

for {
input, err := reader.ReadString('\n')

if err != nil {
fmt.Println("输入出现错误,请重试", err)
return
}
input = strings.TrimSuffix(input, "\n") // 去除换行符

guess, err := strconv.Atoi(input) // 字符串转数字
if err != nil {
fmt.Println("转换出现错误,请重试", err)
return
}

if guess > secretNumber {
fmt.Println("大了")
} else if guess < secretNumber {
fmt.Println("小了")
} else {
fmt.Println("正确")
break
}
fmt.Println("你猜测的数字是", guess)
}
}

1.4.2 在线词典

通过游览器抓取API,先Copy as cURL,然后打开 https://curlconverter.com/ ,将内容粘贴进去,就会自动生成请求代码(与Postman类似)。通过 https://oktools.net/json2go 生成response的结构体。

代码示例(由于接口变化可能会失效,需要重新抓请求头和响应体):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)

type DictRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
UserID string `json:"user_id"`
}

type DictResponse struct {
Rc int `json:"rc"`
Wiki struct {
} `json:"wiki"`
Dictionary struct {
Prons struct {
EnUs string `json:"en-us"`
En string `json:"en"`
} `json:"prons"`
Explanations []string `json:"explanations"`
Synonym []string `json:"synonym"`
Antonym []string `json:"antonym"`
WqxExample [][]string `json:"wqx_example"`
Entry string `json:"entry"`
Type string `json:"type"`
Related []interface{} `json:"related"`
Source string `json:"source"`
} `json:"dictionary"`
}

func query(word string) {
client := &http.Client{}
request := DictRequest{TransType: "en2zh", Source: word}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
req.Header.Set("authority", "api.interpreter.caiyunai.com")
req.Header.Set("accept", "application/json, text/plain, */*")
req.Header.Set("accept-language", "zh-CN,zh;q=0.9")
req.Header.Set("app-name", "xy")
req.Header.Set("content-type", "application/json;charset=UTF-8")
req.Header.Set("device-id", "5c0f93dfde4138785373007fcda72e8d")
req.Header.Set("origin", "https://fanyi.caiyunapp.com")
req.Header.Set("os-type", "web")
req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
req.Header.Set("sec-ch-ua", `"Chromium";v="104", " Not A;Brand";v="99", "Google Chrome";v="104"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "cross-site")
req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36")
req.Header.Set("x-authorization", "token:qgemv4jr1y38jyq6vhvi")
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
bodyText, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
if resp.StatusCode != 200 {
log.Fatal("bad StatusCode:", resp.StatusCode, "body", string(bodyText))
}
var dictResponse DictResponse
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
log.Fatal(err)
}
fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs)
for _, item := range dictResponse.Dictionary.Explanations {
fmt.Println(item)
}
}

func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, `usage: simpleDict WORD example: simpleDict hello`)
os.Exit(1)
}
word := os.Args[1]
query(word)
}

运行结果:

1
2
3
(base) ➜  go run dict.go nice   
nice UK: [nais] US: [naɪs]
a.好的;令人愉快的;精细的;狡黠的;规矩的;讲究的;文雅的;谨慎的;坏的;放肆的;浪荡的

1.4.3 SOCKS5代理Demo

https://wiyi.org/socks5-protocol-in-deep.html

https://github.com/wangkechun/go-by-example/blob/master/proxy/v4/main.go

二. 语言进阶

2.1 Gorountine - 协程

2.1.1 并发与并行

并发:多线程程序在一个核的CPU上运行

并行:多线程程序在多个核的CPU上运行

Goroutine,协程(用户态,轻量级,栈KB级别),线程(内核态,跑多个协程,栈MB级别)

Go语言中开启协程案例:

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

import (
"fmt"
"time"
)

func main() {
for i := 0; i < 5; i++ {
go func(j int) { // 使用go标识符创建协程
fmt.Println(j)
}(i)
}
time.Sleep(time.Second)
}

2.1.2 Channel通道

Go提倡通过 通信 共享内存

通过make可以创建无缓冲通道 make(chan int)或者有缓冲通道 make(chan int, 2)

下面创建一个A、B协程,前者负责生成数字到无缓冲通道中,后者负责从无缓冲通道取出数字,做平方后放入新的有缓冲通道中。

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

func main() {
src := make(chan int)
dest := make(chan int, 3)
// A协程
go func() {
defer close(src) // defer延迟执行,先defer的最后执行(栈结构)
for i := 0; i < 10; i++ {
src <- i
}
}()

// B协程
go func() {
defer close(dest)
for i := range src {
dest <- i << 2 // i^2
}
}()
for j := range dest {
println(j)
}
}

2.1.3 并发安全 Lock

如果共享内存,会存在并发安全问题。

用两个协程进行测试,分别执行加锁和不加锁的函数,对比其差异:

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

import (
"sync"
"time"
)

var (
x int64
lock sync.Mutex
)

func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}

func add() {
for i := 0; i < 2000; i++ {
x += 1
}
}

func main() {
// 分别创建5个协程,分别+2000,总体+10000
x = 0
for i := 0; i < 5; i++ {
go add()
}
time.Sleep(time.Second)
println("NoLock: ", x) // 6225,执行结果不一致

x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock: ", x) // 10000
}

2.1.4 WaitGroup

通过WaitGroup内置的计数器,可以实现阻塞等功能,如下所示:

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

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup // 定义一个wg
wg.Add(5) // 设置阈值为5

for i := 0; i < 5; i++ {
go func(j int) { // 使用go标识符创建协程
defer wg.Done() // Done一次计数器++
fmt.Println(j)
}(i)
}
wg.Wait() // 计数器到5继续执行这一步
}

2.2 依赖

2.2.1 管理

简单来说:站在巨人的肩膀上,因为工程项目不可能基于简单的编码搭建

演进历史:GOPATH -> Go Vendor -> Go Module,为了解决不同环境依赖版本不同的问题,以及控制依赖库的版本。

三要素:1. 配置文件,描述依赖(go.mod)2. 中心仓库管理依赖库(Proxy) 3. 本地工具(go get/mod)

依赖关系举例

2.2.2 分发

如果直接从GitHub等第三方构建项目,无法保证构建的稳定性、可用性(软件可以被增删改),且增加了第三方平台压力。

所以,使用Proxy保证稳定性。例如公共GOPROXY是一个集中式的存储库,全球各地的Golang开发者都可以使用它。它缓存了大量开源的Go模块,这些模块可以从第三方公开访问的VCS项目存储库中获得。

2.2.3 工具

go get

go mod init/download/tidy

2.3 测试

测试是避免事故的最后一道屏障

回归测试 -> 集成测试 -> 单元测试,从上到下,覆盖率逐层变大,成本逐层降低

2.3.1 单元测试

  • 所有测试文件以_test.go结尾

  • func TestXxx(*testing.T)

  • 初始化逻辑放在 TestMain 中

  • 示例:

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

    func HelloBanana() string {
    return "banana"
    }

    /* hello_test.go */
    package main

    import (
    "github.com/stretchr/testify/assert"
    "testing"
    )

    func TestHelloBanana(t *testing.T) {
    output := HelloBanana()
    expectOutput := "banana"
    assert.Equal(t, expectOutput, output) // 用第三方包对比
    }

    然后执行命令:

    1
    2
    3
    4
    5
    (base) ➜  go test -v hello.go hello_test.go
    === RUN TestHelloBanana
    --- PASS: TestHelloBanana (0.00s)
    PASS
    ok command-line-arguments 0.007s
  • 覆盖率

    表示要测试的函数运行了多少行,即覆盖比例,在测试命令后加 --cover 即可

    一般覆盖率50%-60%,较高覆盖率80%+

  • Mock、基准测试(压力测试) 略

三. 存储数据库

3.1 认识存储与数据库

3.1.1 初识

案例:数据产生(用户注册输入的信息)-> 后端服务器 -> 数据库

持久化过程:检验数据合法性 -> 修改内存 -> 写入存储介质

什么是存储系统:提供读写、控制类接口,能够安全有效地把数据持久化的软件,称为存储系统

系统特点:性能敏感、易受硬件影响、代码既简单又复杂(IO代码简洁,错误处理复杂)

缓存 很重要,贯穿整个存储体系。拷贝 很昂贵,要尽量减少。

3.1.2 RAID技术

单机存储系统如何:高性能?高性价比?高可靠性?

Redundant Array of Inexpensive Disks(廉价磁盘冗余阵列)

RAID0:多块磁盘简单组合,提高磁盘带宽,没有额外容错设计

RAID1:一块磁盘对应一块额外镜像盘,真实利用率50%

也可以RAID0+RAID1,结合各种方式的优点

3.1.3 数据库

关系型数据库、非关系型数据库

关系:反应了事物间的关系

SQL:DSL,方便人类阅读的关系代数表达式

ACID特性:原子性、一致性、隔离性、持久性

3.1.4 主流产品剖析

  • 单机存储

    单个计算机节点上的存储软件系统,不涉及网络交互。

    例如Linux中一切皆文件,两大数据结构Index Node & Directory Entry

  • key-value

    常见使用方式:put(k, v) & get(k)

    常见数据结构:LSM-Tree,牺牲读性能,追求写入性能

  • 分布式存储

    在单机存储基础上实现了分布式协议,涉及大量网络交互

    HDFS(一切皆文件)、Ceph(一切皆对象)

  • 单机数据库-关系型数据库

    MySQL & PostgreSQL

  • 非关系型数据库

    MongoDB、Redis、ElasticSearch

  • 分布式数据库解决问题1:容量问题

    单点容量有限,受硬件限制。将存储节点池化,动态扩缩容。

  • 分布式数据库解决问题2:弹性问题

  • 分布式数据库解决问题3:性价比问题

3.1.5 存储与数据库的新技术演进

概述:

  • 软件架构变更:Bypass OS kernel
  • AI增强:智能存储格式转换
  • 硬件革命:存储介质、计算单元、网络硬件变更

详细来说:

  • SPDK

    避免syscall带来性能损耗,用户态访问磁盘

    轮询代替中断,提高性能

    使用无锁数据结构

  • AI & Storage

    行存、列存、行列混存(AI决策)

  • 高性能硬件

    RDMA网络、介于SSD和Memory间的Persistent Memory、可编程交换机、CPU/GPU/DPU

3.2 Database/sql 及 GORM 相关解读

Quick Start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var user User
rows, e := DB.Query("select * from user where id in (1,2,3)")
if e == nil {
errors.New("query incur error")
}
for rows.Next(){
e := rows.Scan(user.sex, user.phone, user.name, user.id, user.age)
if e != nil{
fmt.Println(json.Marshal(user))
}
}
rows.Close()
//单行查询操作
DB.QueryRow("select * from user where id=1").Scan(user.age, user.id, user.name, user.phone, user.sex)

设计原理:应用程序 (操作接口) database/sql (连接接口、操作接口)数据库

DB连接类型:直接连接/Conn 预编译/Stmt 事务/Tx

CRUD操作:https://www.liwenzhou.com/posts/Go/mysql/

模型定义:约定优于配置 https://gorm.io/docs/conventions.html

关联操作:https://cloud.tencent.com/developer/article/1720722

3.3 GORM设计原理与最佳实践

3.3.1 设计原理

GORM STATEMENT:https://gorm.io/zh_CN/docs/models.html

插件:Finisher Method -> 决定Statement类型 -> 执行Callbacks -> 生成SQL并执行

3.3.2 最佳实践

SQL表达式查询:使用gorm.Expr / Struct定义GormValuer / 自定义查询SQL实现接口 / SubQuery

四. 消息队列

4.1 场景举例

  1. 系统崩溃

    解决方案:解耦

    解耦
  2. 服务处理能力有限

    解决方案:削峰

    削峰
  3. 链路耗时长

    解决方案:异步

    异步
  4. 日志如何处理

    Log -> 消息队列 -> LogStash -> ES -> Kibana

  5. 什么是消息队列?

    MQ指保存消息的一个容器,本质是个队列,要支持 高吞吐、高并发、高可用

    Kafka / RocketMQ / Pulsar / BMQ

    用于日志信息、Metrics数据、用户行为等

4.2 基本概念

Topic:逻辑队列

Cluster:物理集群

Producer:生产者

Consumer:消费者

ConsumerGroup:消费者组

4.3 Kafka

消息:Kafka 中的数据单元被称为消息,也被称为记录,可以把它看作数据库表中某一行的记录。

批次:为了提高效率, 消息会分批次写入 Kafka,批次就代指的是一组消息。

主题:消息的种类称为 主题(Topic),可以说一个主题代表了一类消息。相当于是对消息进行分类。主题就像是数据库中的表。

分区:主题可以被分为若干个分区(partition),同一个主题中的分区可以不在一个机器上,有可能会部署在多个机器上,由此来实现 kafka 的伸缩性,单一主题中的分区有序,但是无法保证主题中所有的分区有序。

偏移量:偏移量(Consumer Offset)是一种元数据,它是一个不断递增的整数值,用来记录消费者发生重平衡时的位置,以便用来恢复数据。

broker: 一个独立的 Kafka 服务器就被称为 broker,broker 接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。

broker 集群:broker 是集群 的组成部分,broker 集群由一个或多个 broker 组成,每个集群都有一个 broker 同时充当了集群控制器的角色(自动从集群的活跃成员中选举出来)。

副本:Kafka 中消息的备份又叫做 副本(Replica),副本的数量是可以配置的,Kafka 定义了两类副本:领导者副本(Leader Replica) 和 追随者副本(Follower Replica),前者对外提供服务,后者只是被动跟随。

重平衡:Rebalance。消费者组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。Rebalance 是 Kafka 消费者端实现高可用的重要手段。

Kafka系统架构

Producer:批量发送、数据压缩

Broker:顺序写,消息索引,零拷贝

Consumer:Rebalance

4.4 BMQ消息队列

4.4.1 简介

BMQ 兼容 Kafka 协议,支持存算分离,支持云原生消息队列。

  • 新增 Proxy 层作为代理;
  • Coordinator 和 Controller 可以独立部署;
  • 底层新增 HDFS 用于存算分离。

4.4.2 BMQ 读写流程

在 BMQ 中,客户端写入前会选择一定数量的 DataNode,这个数量一般是副本数,然后将一个文件写入到这几个节点上,切换到下一个 segment 之后,又会重新选择节点进行写入。这样对于单个副本的所有 segment,会随机写入到集群当中。

对于写入逻辑,BMQ 还有一个状态机的机制(Broker-Partition),用来保证不会出现同一个分片在两个 Broker 上同时启动的情况,另外也能保证一个分片的正常运行。

BMQ 的具体写入过程:

  1. CRC 数据校验参数是否合法。
  2. 校验完成后,会把数据放入 Buffer 中。
  3. 通过一个异步的 Write 线程将数据最终写入到底层的存储系统。

4.5 RocketMQ

RocketMQ是由阿里捐赠给Apache的一款低延迟、高并发、高可用、高可靠的分布式消息中间件。经历了淘宝双十一的洗礼。RocketMQ既可为分布式应用系统提供异步解耦和削峰填谷的能力,同时也具备互联网应用所需的海量消息堆积、高吞吐、可靠重试等特性。

五. 从需求到上线全流程

5.1 走进后端开发流程

  • 为什么要有流程?

    个人开发者不需要流程,但超过一个人的团队需要协作,规模越大越容易出现问题

  • 传统瀑布模型

    需求 -> 开发 -> 测试 -> 发布 -> 运维(较为低效)

  • 敏捷开发

    以小团队快速迭代,以人为本和用户沟通

  • Scrum

    在软件工程中,Scrum是以经验过程为依据,采用迭代、增量的方法来提高产品开发的可预见性并控制风险的理论,Scrum不是一种过程,也不是一项构建产品的技术,而是一个框架,在Scrum框架中可以应用各种过程和技术,Scrum的作用是让开发实践方法的相对功效显现出来以便随时改进。 Scrum是敏捷(Agile)开发的一种实践模式,敏捷开发强调拥抱需求变化,快速响应不断变化的需求,并尽可能快地提供可以工作的软件产品,敏捷最强调的是可以正常工作的软件产品,文档等不是非常的强调(并非不要文档,只是需要必要的文档),敏捷理论认为面对面的沟通交流远比文档更有效。 敏捷开发的Scrum模式是以价值驱动(Value-Driven)的开发模式,即认为用户的需求并不一定需要100%实现,最重要的是将对用户最有价值的功能实现并交付。

  • Scrum Master

    是Scrum教练和团队带头人,确保团队合理的运作Scrum,并帮助团队扫除实施中的障碍。

  • Product Owner

    产品负责人,确定产品的方向和愿景,定义产品发布的内容、优先级及交付时间,为产品投资回报率负责。

  • 团队流程举例

    团队流程举例

5.2 开发流程拆解

5.2.1 需求阶段

不要浪费时间讨论不该存在的问题

程序员日常:砍产品经理的需求

MVP(最小化可行产品)思想:站在用户的角度思考,收集用户反馈,快速迭代,如下图所示:

MVP思想

时间安排:

时间安排

5.2.2 开发阶段

如今进入了云原生开发时代,对比:

  • 传统虚拟机

    在物理主机中虚拟多个虚拟机,每个虚拟机有自己的操作系统

    运维人员负责维护和交付虚拟机

    每个虚拟机要安装相应的依赖环境

  • 容器化

    在操作系统中虚拟出来。

    通过cgroup,namespace和union mount等技术实现容器隔离,开销也很低

    应用和依赖作为一个整体,打包成镜像交付

  • 单体架构

    多个模块共同组成一个服务,体量较大

    模块之间直接调用,不需要RPC通信

    服务整体扩缩容量

    多人开发一个代码仓库,需要充分集成调试

  • 微服务架构

    各个功能在不同服务中

    不同模块使用RPC通信

    不同模块可以独立扩缩容

    每个服务的代码仓库仅由少部分人维护

在团队的分支策略决策上,详见Git的使用。

还有设计问题:

  • 代码规范

    良好的注释习惯

    不要有魔法数字、魔法字符串

    重复的逻辑要抽象成公共的方法,不要copy代码

  • 自测

    单元测试

    功能环境测试

    测试数据构造

  • 文档

    要有技术设计文档,便于评审

    好的API文档,方便与前端沟通

5.2.3 测试阶段

越早发现BUG修复的成本越低,可以用一个测试金字塔表达:

测试金字塔

修复时间与成本关系图:

关系图

环境:

  • 功能环境

    需要一个能模拟线上的环境进行开发与测试

    环境之间要相互隔离,不影响其他功能开发与测试

  • 集成环境

    不同人开发的功能要合并在一起测试,相互之间影响可能产生缺陷

    迭代发布的所有功能合并在一起测试,确保发布的功能之间影响不产生缺陷

  • 回归环境

    确保新功能不会对老功能产生影响

    借助自动化测试脚本

5.2.4 发布阶段

  • 发布负责人

    负责按照计划执行发布

    通知人员发布进展

    观察服务的发布状态,及时处理异常

  • 变更服务相关的RD

    按照上线checklist检察服务日志、监控,响应上线过程中的告警

    对于自己负责的改动,在小流量或者是预览环境进行功能验证

    执行发布计划中的其他操作(线上配置、数据处理等)

  • 值班同学

    关注发布过程中的监控和告警,如果是因为变更引起的要及时终止发布

  • 发布模式

    蛮力发布:直接用新版本覆盖老板本,优点是成本低简单,缺点是发布过程中服务会中断,适用于非核心业务

    金丝雀发布:用少量用户验证新版本功能,缺点是发现不了随用户量增大才暴露的问题,适用于非核心业务

    滚动发布:每个实例都通过金丝雀方式发布,可以平滑切换流量(字节常用)

    蓝绿发布:将服务分为两组,用一半服务承接用户,另一半升级,最终两组全部升级,适用于服务器有限的情况,但这样负载也会更高

    红黑发布:与蓝绿发布类似,但发布时动态扩容出一组新的服务,要求服务器资源丰富

5.2.5 运维阶段

  • 可能的问题:

    用户量增加引起流量洪峰

    数据库表中数据量增长导致查询速度变慢

    内存泄漏导致服务资源不足

    光缆被挖断

总结:从需求到上线全流程经过 需求 开发 测试 发布 运维 这五个阶段,每个阶段都有值得我们学习的细节,并思考为什么要这么做,最后的目的是为用户提供良好的服务。

5.3 流程怎样优化

  • 如今:

    重视质量的团队,效率往往较低

    重视效率的团队,事故往往较多

    但随着技术的发展,质量和效率可以同时提高

  • DevOps:

    代码管理、自动化测试、持续集成、持续交付产品交付

  • 日常安排

    周一:产品功能演示和反思会

    周二:Grooming会议,需求规划会议,在backlog中确认需求

    周三:提交测试和发布申请,对其他人提交的工单进行Code Review

    周四:发布过程中如果出现异常,及时排查问题,如果是自己的代码有问题要回滚和修复

    周五:Planning会议,把需求的工作量进行评估,对时间进行排期

六. 经典排序算法

作为一种不稳定的混合排序算法,pdqsort 的不同版本被应用在 C++ BOOST、Rust 以及 Go 1.19 中。它对常见的序列类型做了特殊的优化,使得在不同条件下都拥有不错的性能,本节课将详细介绍其实践步骤。

6.1 例子:抖音直播排行榜功能

规则:某个时间段内,直播间礼物数TOP10房间获得奖励,每个房间展示排行榜

解决方案:

  1. 礼物数量存储在Redis-zset中,使用skiplist使得元素整体有序
  2. 使用Redis集群,避免单机压力过大,使用主从算法、分片算法
  3. 保证集群原信息的稳定,使用一致性算法
  4. 后端使用缓存算法(LRU)降低Redis压力,展示房间排行榜

总结:数据结构和算法几乎存在于程序开发中的所有地方

6.2 经典排序算法

  • 插入排序

    将元素不断插入已排序好的array中,最好O(n),平均o(n^2),最差O(n^2)

    缺点:平均和最差时间复杂度较高

    优点:最好情况时间复杂度低

  • 快速排序

    最好情况是选中的轴点恰好是中位数,即刚好能平分数组,最好O(n*logn),平均O(n*logn),最差O(n^2)

  • 堆排序

    时间复杂度均为O(n*logn)

  • random测试

    random序列测试
  • 有序测试

    有序序列测试
  • 结论

    短序列和元素有序的情况,插入排序性能最好(单车)

    大部分情况下,快速排序有较好的综合性能(汽车)

    任何情况下,堆排序表现稳定(地铁)

    因此,我们能否可以结合这几种 “交通工具”,设计一个更好的算法?

6.3 从零开始打造pdqsort

作为一种不稳定的混合排序算法,pdqsort 的不同版本被应用在 C++ BOOST、Rust 以及 Go 1.19 中。它对常见的序列类型做了特殊的优化,使得在不同条件下都拥有不错的性能,本节课将详细介绍其实践步骤。

6.3.1 Version 1

结合三种排序方法的优点:

  • 对于短序列(小于一定长度)使用插入排序(在泛型版本中选定24)
  • 其他情况,使用快速排序保证整体性能(当最终pivot位置离序列两段很接近,距离小于length/8时,这种情况达到limit次,切换到堆排序)
  • 当快速排序表现不佳,使用堆排序保证最坏情况下时间复杂度仍为O(n*logn)

6.3.2 Version 2

继续针对快速排序部分优化,我们可以从pivot的选择入手:

  • 使用首个元素作为pivot(最简单方案)

  • 遍历数组,寻找中位数(代价高,性能不好)

  • 优化选择:

    短序列(小于等于8),选择固定元素;

    中序列(小于等于50),采样三个元素,取其中的中位数

    长序列(大于50),采样九个元素,取其中的中位数

  • 上面的采样方法使得我们可以探知序列当前状态:

    采样的元素是逆序 -> 序列可能 逆序 -> 翻转序列

    采样的元素是顺序 -> 序列可能 有序 -> 插入排序(限制次数)

6.3.3 Version 3

优化重复元素很多的情况:

采样pivot的时候检测重复度?但概率有点低。

解决方案:

  1. 如果两次分割的pivot相同,即进行了无效分割,这时认为pivot的值为重复元素。

  2. 当pivot选择策略表现不佳,随机交换元素。

pdqsort

淡黄色:可能发生,可能不发生

最终时间复杂度:最好O(n),平均O(n*logn),最差O(n*logn)

6.4 总结

Q:高性能排序算法如何设计?

A:根据不同情况选择不同策略,取长补短

Q:生产环境中使用的排序算法和课本上的排序算法区别?

A:理论算法注重理论性能,如时间、空间复杂度。生产环境中算法需要面对不同实践场景,注重实践性能。

Q:Go(<=1.18)的排序算法是?

A:混合排序算法,主体是快速排序,和1.19后的pdqsort区别在于 fallback时机、pivot选择策略、是否针对不同pattern优化 等。

七. 高质量编程与性能调优实践

7.1 高质量编程

什么是高质量——编写的代码正确可靠、简洁清晰(边界条件、异常处理、可维护性)

  • 注释

    包中声明的每个公共的符号,不需要注释实现接口的方法

    解释代码的作用、如何做的、实现的原因、什么情况会出错

  • 代码格式

    建议使用gofmt自动格式化代码

  • 命名规范

    简洁胜于冗长

    驼峰命名法

    变量距离其被使用的地方越远,需要携带越多的上下文信息

    包名由小写字母构成,不要与标准库同名,使用单数而不是复数

  • 控制流程

    避免嵌套,保持正常流程清晰

  • 异常处理

    Wrap和Unwrap

    不建议在业务代码中使用panic,建议使用error代替panic

    recover只能在被defer的函数中使用(defer后进先出)

7.2 性能优化建议

性能优化的前提是满足正确可靠、清晰简洁等质量因素

性能优化是综合评估,有时候时间效率和空间效率可能对立

针对Go语言特性,介绍Go相关的性能优化建议

Benchmark

Go语言提供了支持基准性能测试的benchmark工具

go test -bench=. -benchmem

优化建议 - Slice:

切片的本质是一个数组片段的描述,包含以下三项:

  • 数组指针 array unsafe.Pointer
  • 片段的长度 len
  • 片段的容量 cap (不改变内存分配情况下的最大长度)

尽可能在使用 make() 初始化切片时提供容量信息。这是因为向切片中添加的元素数量超过默认容量会触发扩容机制,扩容是一个比较耗时的操作。

切片使用陷阱:大内存未释放

  • 场景:
    • 原切片较大,代码在原切片基础上新建小切片。
    • 原底层数组在内存中有引用,得不到释放。

这是由于 Golang 中在已有切片的基础上创建切片,不会创建新的底层数组,而是直接复用原来的。如果只是需要用到其中的一小部分,复用原来的整个数组会导致占用较大的内存空间,建议使用 copy 替代 re-slice。

优化建议 - Map:

同样的,map 也建议预分配内存来避免扩容机制的时间开销。

  • 不断向 map 中添加元素会触发 map 的扩容。
  • 提前分配好空间可以减少内存拷贝和 Rehash 的消耗。
  • 建议根据实际需求提前预估好需要的空间。
优化建议 - 字符串处理:

和 Java 语言类似,Golang 中直接使用 + 拼接字符串是一种十分低效的方式,因为字符串是不可变类型,使用 + 每次都会重新分配内存,推荐使用 strings.Builderbytes.Buffer 操作字符串(strings.Builder 效率要更高一些)。

优化建议 - 空结构体:

使用空结构体 struct{} 可以节省内存。

  • 空结构体实例不占据任何的内存空间。
  • 可作为各种场景下的占位符使用。
    • 节省资源。
    • 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符。

比如在实际的开发中,我们经常会使用到 Set 这种数据结构,然而 Golang 本身并不支持 Set,我们可以考虑用 map 来代替。换句话说我们只用到 map 的键,而不用它的值,那么值可以用 struct{} 类型占位。

优化建议 - atomic 包:

atomic 包主要用在多线程编程,相比于加锁的方式来保证并发安全,atomic 包效率更高。

  • 锁的实现是通过操作系统来实现,属于系统调用。
  • atomic 操作是通过硬件实现,效率比锁高。
  • sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量,因此成本比较大。
  • 对于非数值操作,可以使用 atomic.Value,能承载一个 interface{}

7.3 性能分析工具

分析-Profile

  • 网页
  • 可视化终端

工具-Tool

  • runtime/pprof
  • net/http/pprof

展示-View

  • Top
  • 调用图-Graph
  • 火焰图-Flame Graph
  • Peek
  • 源码-Source
  • 反汇编-Disassemble

采样-Sample

  • CPU
  • 堆内存-Heap
  • 协程-Goroutine
  • 锁-Mutex
  • 阻塞-Block
  • 线程创建-ThreadCreate

排查实战:

要搭建一个pprof实践项目,首先下载源代码,达到能够编译运行的目的,且这一操作会占用1个CPU核心以及超过1GB的内存,提前埋入了一些炸弹代码,注意电脑性能。通过Github链接学习:go-pprof-practice

对 CPU 检查:
除了在浏览器内打开本地服务器网页端进行检查,还可以通过go tool:

go tool pprof “http://localhost:6060/debug/pprof/profile?seconds=10

输入 top 命令,查看CPU占用情况,可以找到占用资源最多的函数。其中有一些指标:

flat 当前函数本身的执行耗时
flat% flat占CPU总时间的比例
sum% 上面每一行的flat% 总和
cum 指当前函数本身加上其调用函数的总和
cun% cum占CPU总时间的比例

输入 **list **命令,可以根据指定的正则表达式查找代码行。
输入 web 命令,可以调用关系可视化。

对堆内存检查:

go tool pprof -http=:8080 “http://localhost:6060/debug/pprof/heap

输入命令后,点击视图View可以选择不同的种类View,Sample有alloc_objects作为程序累计申请的对象数量、inuse_objects作为程序当前持有的对象数量、alloc_space作为程序累计申请的内存大小, inuse_space作为程序当前占用的内存大小。

7.4 性能调优实战案例

cpu:

采样对象:函数调用和它们占用的时间

采样率:100次每秒,固定值

采样时间:从手动启动到手动结束

操作系统:每10秒向进程发送一次SIGPROF信号

进程:每次接受SIGPROF会记录调用堆栈

写缓冲:每100毫秒读取已经记录的调用栈并写入输出流

Heap堆内存:

采样程序通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量

采样率:每分配512kb记录一次,可在运行开头修改,1为每次分配均记录

采样时间:从程序运行开始到采样时

采样指标:alloc_space,alloc_objects,inuse_space,inuse_objects

计算方式:inuse = alloc-free

Gorountine 协程 & ThreadCreae 线程创建:

Goroutine:记录所有用户发起且在运行中的 gorountine (即入口非runtime开头的) runtime.main 的调用栈信息

threadCreate:记录程序创建的所有系统线程信息

Block & Mutex 锁:

阻塞操作:

采样阻塞操作的次数和耗时

采样率:阻塞耗时超阈值值的才会被记录,1为每次阻塞均记录

锁竞争:

采样争抢锁的次数和耗时

采样率:只记录固定比例的锁操作,1为每次加锁均记录

性能调优案例:

  1. 业务服务优化,基本概念:

    服务:能单独部署,承载一定功能的程序

    依赖: Service A 的功能实现依赖 Service B 的响应结果,称为 Service A 依赖 Service B

    调用链路: 能支持一个接口请求的相关服务集合及其相互之间的依赖关系

    基础库:公共的工具包、中间件

  2. 流程

    建立服务性能评估手段

  3. 基础库优化

    分析基础库核心逻辑和性能瓶颈

    • 设计完善改造方案
    • 数据按需获取
    • 数据序列化协议优化
  4. go语言优化

    编译器&运行时优化

    • 优化内存分配策略
    • 优化代码编译逻辑,生成更高效的程序
    • 内部压测验证
    • 推广业务服务落地验证

    优点

    • 接入简单,只需要调整编译配置
    • 通用性强

总结:

性能调优原则:要依靠数据不是猜测

性能分析工具 pprof:熟练使用 pprof 工具排查性能问题并了解其基本原理

八. RPC

8.1 RPC框架分层设计

  • RPC:远程函数调用

  • 解决问题:函数映射、数据转换成字节流、网络传输

  • 一次RPC的完整过程:

    IDL(Interface description language) 文件:它通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以互相通信

    生成代码:通过编译器工具把IDL文件转换成语言对应的静态库

    编解码:从内存中表示到字节序列的转换称为编码,反之为解码,也叫做序列化和反序列化

    通信协议:规范了数据在网络中传输的内容和格式

    网络传输:通常基于TCP/UDP

  • RPC好处:

    单一职责,有利于分工协作和运维开发

    可扩展性强,资源使用率更优

    故障隔离,服务的整体可靠性更高

  • RPC问题:

    服务宕机,对方该如何处理?

    如果调用过程发生网络异常,如何保证消息可达性?

    请求量突增导致服务无法及时处理,如何应对?

8.2 RPC关键指标分析

构建一个RPC框架需要考虑哪些指标呢?需要考虑:稳定性、易用性、扩展性、观测性、高性能。

稳定性:

既然是稳定性,肯定要有保障策略,有以下几个策略:

  • 熔断
    • 保护调用方,防止调用的服务出现问题而影响到整个链路
  • 限流
    • 保护被调用方,防止大流量把服务压垮
  • 超时控制
    • 避免浪费资源在不可用节点上

从某种程度上讲超时、限流和熔断也是一种服务降级的手段 。

请求成功率:

提高请求成功率,要从负载均衡、重试方便出发。

在重试时,要防止重试风暴,限制单点重试和限制链路重试。

长尾请求:

长尾请求一般是指明显高于均值的那部分占比较小的请求。 业界关于延迟有一个常用的P99标准, P99 单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99 值,那后面这 1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动、GC、系统调度。

注册中间件:

上面提到的是稳定性的措施和策略,那么如何把这些措施和错略进行串联起来呢?是通过中间件的形式。

易用性:

  • 开箱即用
    • 合理的默认参数选项、丰富的文档
  • 周边工具
    • 生成代码工具、脚手架工具

扩展性:

需要提供尽量多的扩展点,扩展点包括:

  • 中间件
  • Option、参数
  • 编解码层
  • 协议层
  • 网络传输层
  • 代码生成工具插件扩展

观测性:

观测性可以方便追踪、排查问题,包括:LogMetricTracing

除了传统的 LogMetricTracing 三件套之外,对于框架来说可能还不够,还有些框架自身状态需要暴露出来,例如当前的环境变量、配置、Client/Server初始化参数、缓存信息等。

高性能:

做到高性能,我们的目标是:高吞吐、低延迟。

衡量标准覆盖场景:

  • 单机多机
  • 单连接、多连接
  • 单/多client、单/多Server
  • 不同大小的请求包
  • 不同请求类型:例如pingpongstreaming

要达到目标需要的手段:

  • 连接池
  • 多路复用
  • 高性能编解码协议
  • 高性能网络库

九. 架构

9.1 架构定义解析

架构,又称为软件架构:

  • 是有关软件整体结构与组建的抽象描述
  • 用于指导软件系统各个方面设计

重要性:

  • 地基没打好,大厦容易倒
  • 地基坚实了,大厦才能盖的高
  • 站在巨人肩膀上,才能看得远

1.单机架构:

所有功能实现在一个进程里,并部署在一台机器上。

优点是简单,问题是C10K problem / 运维需要停服。

2.垂直应用架构:

按应用垂直切分。

优点是水平扩容 / 运维不需要停服,问题是指责太多,开发效率不高,爆炸半径大。

3.SOA(Service-Oriented Architecture) 架构:

将应用的不同功能单元抽象为服务,定义服务之间的通信标准。

问题:数据一致性 / 高可用 / 容灾 / 解耦 vs 过微

9.2 企业级后端架构剖析

9.2.1 云计算

云计算:是指通过软件自动化管理,提供计算资源的服务网络,是现代互联网大规模熟悉分析和存储的基石
基础:

  • 虚拟化技术
  • 编排方案 架构:
  • 基础设施即服务(Infrastructure as a Service, IaaS)
    • 利用基础设施即服务 (IaaS),云服务提供商可以拥有并管理那些运行您的软件堆栈的硬件。它包括服务器、网络和存储。如果您不想购买和维护基础设施,这便是一个可以大大降低成本的战略。但是您的 IT 团队仍有大量工作要做。在 IaaS 模型下,您的 IT 团队需要管理操作系统 、数据库、应用程序、功能和您的组织的所有数据。因此,与其它服务模型相比,您的 IT 团队将具有更大的控制力和灵活性。IaaS 是自助服务,您的 IT 团队可以通过 API 或仪表板获取所需资源。它的常见示例包括亚马逊 AWS、谷歌计算引擎和微软 Azure,您可以通过它们购买自己所需的容量。也就是说,它几乎不涉及约定,如果您认为自己的需求在不久以后会有变化,它就会体现出优势。如果您属于一个大型组织,您也可以通过您的企业的另一个部分访问 IaaS。
  • 平台即服务(Platform as a Service, PaaS)
    • 下一级服务是平台即服务 (PaaS)。PaaS 与 IaaS 相似,区别在于您的云服务提供商还提供了操作系统和数据库。这意味着您的 IT 团队的工作量较少, 但您的组织仍然要负责应用程序、功能和数据。PaaS 为您的开发者提供了一个简单、可扩展的应用程序构建平台。它与 IaaS 非常相似,您可以根据需要购买更多资源。由于多个用户可以访问开发应用程序,因此 PaaS 可以简化工作流程并加强协调。PaaS 的示例包括 AWS Elastic Beanstalk 和谷歌应用程序引擎。
  • 软件即服务(SaaS, Software as a Service)
    • 软件即服务 (SaaS) 为最终用户提供了最多的支持,是所有交付模型中最简单的一种。您可能已经在您的组织中使用过它。SaaS 可以在多租户架构中运行,软件的一个实例可以为多个用户提供服务。一般来说,SaaS 产品不需要下载或安装,您的最终用户不需要管理软件更新。他们只需要负责自己的数据。SaaS 的常见示例包括 CRM 软件、基于云的文件存储和电子邮件。
  • 功能即服务(FaaS, Function as a Service)
    • 提供更深层次的服务。使用 FaaS,您的用户只需管理功能和数据。云服务提供商则管理您使用的应用程序。这种选项在开发者中特别常见,因为您无需在代码未运行时为服务付费。常见功能包括数据处理、数据验证或分类,以及移动和物联网应用程序的后端。FaaS 供应商包括 AWS* Lambda、Azure Functions 和谷歌云 Functions。
  • 裸机即服务 (BMaaS)
    • 一些企业不喜欢将工作负载迁移到与其他客户共享的虚拟化云环境中, 便用裸机即服务 (BMaaS) 方案来替代 IaaS 和 PaaS。它为企业提供了专用服务器环境来补充虚拟化云服务,且该专用服务器环境与云具有相同的敏捷性、可扩展性和效率。特别是,对于需要在没有延迟或延时开销的情况下执行短期数据密集型处理(例如媒体编码或渲染农场)的企业来说,BMaaS 是一个不错的选择。
  • 数据库即服务 (DBaaS)
    • 这是一种提供数据库访问权限的 PaaS。DBaaS 是一种很好的启用混合云的方法,因为应用程序可以在本地和云基础设施之间移动,但对最终用户没有任何影响。通过 DBaaS 集成新技术也简单得多,因为应用程序开发者不需要任何额外资源即可使用新技术。DBaaS 的一个示例是微软* Azure SQL 数据库。

9.2.2 云原生

云原生技术为组织(公司)在公有云、自由云、混合云等新型的动态环境中,构建和运行可弹性拓展的应用提供了可能。

云原生

  • 弹性资源
    • 虚拟化容器
    • 快速扩缩容
  • 微服务架构
    • 业务功能单元解耦
    • 统一的通信标准
  • DevOps
    • 敏捷的开发模式
    • CI/CD
  • 服务网格
    • 业务与治理结构
    • 异构系统的治理统一化
    • 复杂治理能力

9.2.3 企业级后端架构的挑战

挑战:

  • 基础设施层面
    • 物理资源是有限的
      • 机器
      • 带宽
    • 资源利用率受限于部署服务
  • 用户层面
    • 网络通信开销较大
    • 网络抖动导致运维成本提高
    • 异构环境下,不同实例资源水位不均

9.2.4 离在线资源并池

核心收益

  • 降低物理资源成本
  • 提供更多的弹性资源,增加收入

解决思路:离在线资源池

  • 在线业务的特点
    • IO密集型为主
    • 潮汐性,实时性
  • 离线业务的特点
    • 计算密集型占多数
    • 非实时性

9.2.5 自动扩缩容

核心利益

  • 降低业务成本

解决思路: 自动扩缩容

  • 利用在线业务潮汐性自动扩缩容(利用CPU的P50作为指标)

9.2.6 微服务亲和性部署

核心利益

  • 降低业务成本
  • 提高服务可用性

解决方案:微服务亲和性部署

  • 将满足亲和性条件的容器调度到一个宿主机
  • 微服务中间件与服务网格通过共享内存通信
  • 服务网格控制面板实施灵活,动态的流量调度

9.2.7 流量治理

核心收益

  • 提高微服务的调用容错性
  • 容灾
  • 进一步提高开发效率,DevOps发挥到极致

解决方案:基于微服务中间件& 服务网格的流量治理

  • 熔断、重试
  • 单元化
  • 复杂环境(功能,预览)流量调度

9.2.8 CPU水位负载均衡

核心利益

  • 打平异构环境算力差异
  • 为自动扩缩容提供正向输入

解决思路:CPU水位负载均衡

  • IaaS
    • 提供资源探针
  • 服务网格
    • 动态复杂均衡

十. 将我的服务开放给用户

Host管理

  • 背景:example公司建立了多个内部站点,公司的主机表包括办公(aa.example.com)、文档(wiki.example.com)、员工认证(passport.example.com)、人事(people.example.com)。
  • 方法:网络运维人员使用主机表的方式来管理Host到IP的映射。即Host->ip映射
  • 访问:员工通过HTTP协议来拉取Host的配置,从而达到访问不同系统的目的。
  • 问题:随着example公司业务规模和员工数量的增长,问题出现:
    • 流量和负载:用户规模⬆,文件大小⬆,统一分发引起较大的网络流量和cpu负载
    • 名称冲突:无法保证主机名称的唯一性,同名主机添加导致服务故障
    • 时效性:分发靠人工上传,时效性太差

使用域名系统

  • 改进措施:使用域名系统(域名空间)替换hosts文件
  • 域名:根->顶级域名(.edu教育、.com商业、.mil军事、.org非营业组织)->二级域名->三级域名->四级域名

域名购买与配置迁移

  • 购买二级域名:example.com
  • 修改配置:清空/etc/hosts。配置/etc/resolv.conf中nameservers为公共DNS。迁移原配置,通过控制台添加解析记录即可。

如何开放外部用户访问

  • 方案:租赁一个外网IP,专用于外部用户访问门户网站,将 www.example.com 解析到外网IP100.1.2.3,将该IP绑定到一台物理机上,并发布公网route,用于外部用户访问。

自建DNS服务器

  • 内网域名的解析也得出公网去获取,效率低下
  • 外部用户看到内网IP地址,容易被hacker攻击
  • 云厂商权威DNS容易出故障,影响用户体验
  • 持续扩大公司品牌技术影响力,使用自己的DNS系统(包含内外网)

DNS查询过程

  1. 主机发出访问 www.163.com 请求。
  2. 本地DNS服务器发现缓存里没有这个IP地址,向DNS根服务器询问。
  3. DNS根服务器告诉本地DNS服务器可以去.com域服务器查询,本地DNS服务器向.com域服务器询问。
  4. .com域服务器告诉本地DNS服务器可以去163.com域服务器查询,本地DNS服务器向163.com域服务器询问。
  5. 163.com域服务器查询得出此域名IP地址为1.1.1.1并返回给本地DNS服务器。
  6. 本地DNS服务器返回查询结果给主机,并将“www.163.com 的IP是1.1.1.1”的信息存入缓存。

DNS记录类型

  • A/AAAA:IP指向记录,用于指向IP,前者为IPv4记录,后者为IPv6记录
  • CNAME:别名记录,配置值为别名或主机名,客户端根据别名继续解析以提取IP地址
  • TXT:文本记录,购买证书时需要
  • MX:邮件交换记录,用于指向邮件交换服务器
  • NS:解析服务器记录,用于指定哪台服务器对于该域名解析
  • SOA记录:起始授权机构记录,每个zone有且仅有唯一的一条SOA记录,SOA是描述zone属性以及主要权威服务器的记录

权威DNS系统架构

常见的开源DNS:bind、nsd、knot、coredns

接入HTTPS协议

  • 页面出现白页/广告
  • 返回了403的页面
  • 搜索不了东西
  • 搜索问题自带小尾巴,页面闪退频繁
  • 页面弹窗广告:HTTP明文传输,弊端越来越明显
  • 解决办法:将HTTP替换为HTTPS

对称加密和非对称加密

  • 对称加密:加密数据在传输过程中是无规则的乱码,即使被第三方获取,在没有密钥时也无法解密数据,从而保证数据的安全性。
    • 问题:双方都要使用相同的密钥,在传输数据前要将密钥从一方传输给另一方,这样密钥在传输过程中就有可能被截获。
  • 非对称加密:加密和解密采用两个不同的密钥,也就是公钥(锁头)和私钥(钥匙)。公钥和私钥是一对,使用公钥加密就需要私钥解密,私钥加密就公钥解密。
    • 例子:服务器拥有公钥和私钥。当客户端发起请求后,服务器将公钥返回给客户端。客户端生成密钥KEY,使用公钥加密后发送给服务器。服务器利用私钥解密获得密钥KEY。之后,双方使用密钥KEY进行对称加密传播。

SSL的通信过程

服务器和客户端都拥有以下四个属性:

  • client random
  • server random
  • premaster secret
  • 加密算法协商 由此生成对称密钥session key。

证书链

公钥存在证书,Server端发送是带签名的证书链,client收到会仍然需要验证:

  • 是否是可信机构颁布
  • 域名是否与实际访问一致
  • 检查数字签名是否一致
  • 检查证书的有效期
  • 检查证书的撤回状态 此外,服务端会在发送前将证书摘要信息用私钥加密生成数字签名,客户端收到后用公钥解密数字签名,如果与证书摘要信息一致则说明传输无误。

接入全站加速

问题背景

  • 源站容量低,可承载的并发请求数低,容易被打垮
  • 报文经过的网络设备越多,出问题的概率越大,丢包、劫持、mtu问题
  • 自主选路网络链路长,时延高
  • 由此造成客户端响应慢、卡顿。 👉极大的流失了大部分的用户群体,NPS留存率数据不乐观。

解决方案

  • 源站容量问题:增加后端机器扩容;静态内容,使用静态加速缓存
  • 网络传输问题:动态加速DCDN
  • 全站加速:静态加速+动态加速

静态加速CDN

  • 针对静态文件传输,网络优化方式: 通过将服务器上的内容缓存到cdn节点上。当访问静态内容时,无需再访问源站,通过就近访问cdn节点就可达到相同的结结果,从而达到加速的效果,同时减轻了源站服务器的压力。此时主机从本地DNS获取的不是源站DNS的结果,而是cdn节点的解析结果。cdn使用自动调度DNS,根据静态拓扑等算法把一些合适的地址返回给client,并缓存地址。
  • 优点:解决服务器端的“第一公里”问题、缓存甚至消除了不同运营商之间互联的瓶颈造成的影响、减轻了各省的出口带宽压力、优化了网上热点内容的分布

动态加速DCDN

针对POST等非静态请求等不能再用户边缘缓存的业务,基于智能选路技术,从众多回源线路中择优选择一条线路进行传输。

DCDN原理

DCDN会计算从用户到核心、用户到边缘、边缘到汇聚、汇聚到核心等的RTT(Round Trip Time)时间,然后进行常规请求耗时计算,选择最优路径。

使用全站加速

  • 用户首次登录抖音,注册用户名手机号等用户信息:动态加速DCDN
  • 抖音用户点开某个特定短视频加载后观看:静态加速CDN
  • 用户打开头条官网进行网页浏览:静态加速CDN+动态加速DCDN

四层负载均衡

  • 服务混杂,端口多,部门人员在执行操作的时候可能会出错
  • 把所有的服务都部署在一台物理机上,如果物理机突然挂掉,容灾怎么处理?

基于IP+端口,利用某种算法将报文转发给某个后端服务器,实现负载均衡地落到后端服务器上。

  • 三个主要功能:
  1. 解耦vip和rs
  2. NAT
  3. 防攻击:syn proxy

常见的调度算法原理

  • RR轮询:Round Robin,将所有的请求平均分配给每个真实服务器RS
  • 加权RR轮询:给每个后端服务器一个权值比例,将请求按照比例分配
  • 最小连接:把新的连接请求分配到当前连接数最小的服务器
  • 五元组hash:根据sip、sport、proto、dip、dport对静态分配的服务器做散列取模
    • 缺点:当后端某个服务器故障后,所有连接都重新计算,影响整个hash环
  • 一致性hash:只影响故障服务器上的连接session,其余服务器上的连接不受影响

常见的实现方式FULLNAT

  • 包含入向流和出向流。
  • 在发出外网报文请求后,会经过外网核心设备,然后再转发给4层负载均衡GW。GW通过VIP接收请求,但是在内部不能把VIP当作client,所以GW的另一个网卡绑定了LIP,LIP是一个内部IP,能够通过new一个IP与RS进行通信,从而将请求转发给RS,再转发给物理机,然后物理机再将请求回传给GW,然后GW再把请求通过IP转换,通过VIP回传给client。 ❓RS怎么指定真实的CIP? 👉通过TCP option字段传递,然后通过特殊的内核模块反解

4层负载均衡特点

  • 大部分都是通过dpdk技术实现,技术成熟
  • 纯用户态协议栈,kernel bypass,消除协议栈瓶颈
  • 无缓存,零拷贝,大页内存(减少cache miss)
  • 仅针对4层数据包转发,小包转发可达到限速,可承受高cps

7层负载均衡

  • 四层负载对100.1.2.3只能bind一个80端口,而有多个外部站点需要使用,该如何解决? 还有以下问题:
  • SSL卸载:业务端是http服务,用户需要用https访问
  • 请求重定向:浏览器访问toutiao.com自动跳转 www.toutiao.com
  • 路由添加匹配策略:完全、前缀、正则
  • Header编辑
  • 跨域支持
  • 协议支持:websocket、grpc、quic

Nginx简介

  • 最灵活的高性能web server,应用最广的7层反向代理。
  • 模块化设计,较好的扩展性和可靠性
  • 基于master/worker架构设计
  • 支持热部署:可在线升级
  • 不停机更新配置文件、更换日志文件、更新服务器二进制
  • 较低的内存消耗:1万个keep-alive连接模式下的非活动连接仅消耗2.5M内存
  • 事件驱动:异步非阻塞模型,支持aio、mmap(内存映射)

Nginx反向代理

  • 代理服务器功能:Keepalive、访问日志、url rewrite重写、路径别名、基于ip的用户的访问控制、限速及并发连接数控制

事件驱动模型

将每种动作都归纳成一个个独立的事件,而事件之间相互独立,没有影响。可以理解成一个个任务task,然后给每一个task设置一个回调函数。在系统中启动多线程,每一个线程监听一个事件的队列,如果队列不为空,就从队列中取出相应的对象执行回调函数即可。