关于Go语言的底层,你想知道的都在这里!
文章字数大约1.95万字,阅读大概需要65分钟,建议收藏后慢慢阅读!!!
1. GoLang语言
1.1 Slice
-
Slice底层实现原理
切片是基于数组实现的,它的底层是数组,它自己本身非常小,可以理解为对底层数组的抽象。因为基于数组实现,所以它的底层的内存是连续分配的,效率非常高,还可以通过索引获得数据,可以迭代以及垃圾回收优化。 切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一 个只读对象,其工作机制类似数组指针的一种封装。
切片对象非常小,是因为它是只有 3 个字段的数据结构:
-
指向底层数组的指针
-
切片的长度
-
切片的容量
-
-
Slice扩容机制
在使用 append 向 slice 追加元素时,若 slice 空间不足则会发生扩容,扩容会重新分配一块更大的内存,将原 slice 拷贝到新 slice ,然后返回新 slice。扩容后再将数据追加进去。
扩容操作只对容量,扩容后的 slice 长度不变,容量变化规则如下:
- 若 slice 容量小于1024个元素,那么扩容的时候slice的cap就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。
- 若 slice 容量够用,则将新元素追加进去,slice.len++,返回原 slice
- 若 slice 容量不够用,将 slice 先扩容,扩容得到新 slice,将新元素追加进新 slice,slice.len++,返回新 slice。
-
Slice与数组区别
array是固定长度的数组,使用前必须确定数组长度,是值类型。
slice是一个引用类型,是一个动态的指向数组切片的指针。
slice是一个不定长的,总是指向底层的数组array的数据结构,可以动态扩容。创建方式不一样,Slice使用make创建或者根据数组创建。
作为函数参数时,数组传递的是数组的副本,而slice传递的是指针。
1.2 Map
-
Map底层实现原理
Golang 中 map 的底层实现是一个散列表,因此实现 map 的过程实际上就是实现散表的过程。在这个散列表中,主要出现的结构体有两个,一个叫 hmap(a header for a go map),一个叫 bmap(a bucket for a Go map,通常叫其 bucket)。
hmap 哈希表
hmap是Go map的底层实现,每个hmap内都含有多个bmap(buckets桶、oldbuckets旧桶、overflow溢出桶),既每个哈希表都由多个桶组成。-
buckets
buckets是一个指针,指向一个bmap数组,存储多个桶。 -
oldbuckets
oldbuckets是一个指针,指向一个bmap数组,存储多个旧桶,用于扩容。 -
overflow
overflow是一个指针,指向一个元素个数为2的数组,数组的类型是一个指针,指向一个slice,slice的元素是桶(bmap)的地址,这些桶都是溢出桶。为什么有两个?因为Go map在哈希冲突过多时,会发生扩容操作。[0]表示当前使用的溢出桶集合,[1]是在发生扩容时,保存了旧的溢出桶集合。overflow存在的意义在于防止溢出桶被gc。
bmap 哈希桶
bmap是一个隶属于hmap的结构体,一个桶(bmap)可以存储8个键值对。如果有第9个键值对被分配到该桶,那就需要再创建一个桶,通过overflow指针将两个桶连接起来。在hmap中,多个bmap桶通过overflow指针相连,组成一个链表。 -
-
Map进行有序的排序
map每次遍历,都会从一个随机值序号的桶,再从其中随机的cell开始遍历,并且扩容后,原来桶中的key会落到其他桶中,本身就会造成失序
如果想顺序遍历map,先把key放到切片排序,再按照key的顺序遍历map。
或者可以先把map中的key,通过sort包排序,再遍历map。
-
map 为什么是不安全的
Go map 默认是并发不安全的,同时对 map 进行并发读写的时,程序会 panic,原因如下:Go 官方经过长时间的讨论,认为 map 适配的场景应该是简单的(不需要从多个 gorountine 中进行安全访问的),而不是为了小部分情况(并发访问),导致大部分程序付出锁的代价,因此决定了不支持。
map 在扩缩容时,需要进行数据迁移,迁移的过程并没有采用锁机制防止并发操作,而是会对某个标识位标记为 1,表示此时正在迁移数据。如果有其他 goroutine 对 map 也进行写操作,当它检测到标识位为 1 时,将会直接 panic。
如果想实现map线程安全,有两种方式:
方式一:使用读写锁
map
+sync.RWMutex
方式二:使用golang提供的
sync.Map
-
Map扩容策略
扩容时机:
向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容
扩容条件:
-
超过负载 map元素个数 > 6.5(负载因子) * 桶个数
-
溢出桶太多
当桶总数<2^15时,如果溢出桶总数>=桶总数,则认为溢出桶过多
当桶总数>215时,如果溢出桶总数>=215,则认为溢出桶过多
扩容机制:
-
双倍扩容:针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。
-
等量扩容:针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,节省空间,提高 bucket 利用率,进而保证更快的存取。
-
渐进式扩容:
插入修改删除key的时候,都会尝试进行搬迁桶的工作,每次都会检查oldbucket是否nil,如果不是nil则每次搬迁2个桶,蚂蚁搬家一样渐进式扩容
-
-
Map和Slice区别
- 数组:数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。声明方式:var a [3]int
- slice(切片):Slice(切片)代表变长的序列,序列中每个元素都有相同的类型,slice的语法和数组很像,只是没有固定长度而已。
- map:在Go语言中,一个map就是一个哈希表的引用,是一个无序的key/value对的集合
-
Map总结
- map是引用类型
- map遍历是无序的
- map是非线程安全的
- map的哈希冲突解决方式是链表法
- map的扩容不是一定会新增空间,也有可能是只是做了内存整理
- map的迁移是逐步进行的,在每次赋值时,会做至少一次迁移工作
- map中删除key,有可能导致出现很多空的kv,这会导致迁移操作,如果可以避免,尽量避免
1.3 Channel
-
介绍一下Channel(有缓冲和无缓冲)
Go 语言中,不要通过共享内存来通信,而要通过通信来实现内存共享。Go 的CSP(Communicating Sequential Process)并发模型,中文可以叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。
所以 channel 收发遵循先进先出 FIFO,分为有缓存和无缓存,channel 中大致有 buffer(当缓冲区大小部位 0 时,是个 ring buffer)、sendx 和 recvx 收发的位置(ring buffer 记录实现)、sendq、recvq 当前 channel 因为缓冲区不足 而阻塞的队列、使用双向链表存储、还有一个 mutex 锁控制并发、其他原属等。
// 无缓冲的channel由于没有缓冲发送和接收需要同步 ch := make(chan int) //有缓冲channel不要求发送和接收操作同步 ch := make(chan int, 2)
channel 无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据;channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。
-
Channel实现原理
channel 内部维护了两个 goroutine 队列,一个是待发送数据的 goroutine 队列,另一个是待读取数据的 goroutine 队列。
每当对 channel 的读写操作超过了可缓冲的 goroutine 数量,那么当前的 goroutine 就会被挂到对应的队列上,直到有其他 goroutine 执行了与之相反的读写操作,将它重新唤起。
-
Channel读写流程
向 channel 写数据:
若等待接收队列 recvq 不为空,则缓冲区中无数据或无缓冲区,将直接从 recvq 取出 G ,并把数据写入,最后把该 G 唤醒,结束发送过程。
若缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程。
若缓冲区中没有空余位置,则将发送数据写入 G,将当前 G 加入 sendq ,进入睡眠,等待被读 goroutine 唤醒。
从 channel 读数据
若等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G ,把 G 中数据读出,最后把 G 唤醒,结束读取过程。
如果等待发送队列 sendq 不为空,说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程。
如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程。
将当前 goroutine 加入 recvq ,进入睡眠,等待被写 goroutine 唤醒。
关闭 channel
1.关闭 channel 时会将 recvq 中的 G 全部唤醒,本该写入 G 的数据位置为 nil。将 sendq 中的 G 全部唤醒,但是这些 G 会 panic。
panic 出现的场景还有:
- 关闭值为 nil 的 channel
- 关闭已经关闭的 channel
- 向已经关闭的 channel 中写数据
-
Channel为什么能做到线程安全
Channel 可以理解是一个先进先出的队列,通过管道进行通信,发送一个数据到Channel和从Channel接收一个数据都是原子性的。不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。设计Channel的主要目的就是在多任务间传递数据的,本身就是安全的。
-
Channel是同步进行还是异步的(Channel的三种状态)
Channel是异步进行的, channel存在3种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为nil
- active,正常的channel,可读或者可写
- closed,已关闭,千万不要误认为关闭channel后,channel的值是nil
操作 一个零值nil通道 一个非零值但已关闭的通道 一个非零值且尚未关闭的通道 关闭 产生恐慌 产生恐慌 成功关闭 发送数据 永久阻塞 产生恐慌 阻塞或者成功发送 接收数据 永久阻塞 永不阻塞 阻塞或者成功接收 - 给一个 nil channel 发送数据,造成永远阻塞
- 从一个 nil channel 接收数据,造成永远阻塞
- 给一个已经关闭的 channel 发送数据,引起 panic
- 从一个已经关闭的 channel 接收数据,如果缓冲区中为空,则返回一个零值
- 无缓冲的 channel 是同步的,而有缓冲的 channel 是非同步的
- 关闭一个 nil channel 将会发生 panic
1.4 Goroutine
-
进程、线程和协程的区别
-
进程: 进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
-
线程: 线程是进程的一个实体,线程是内核态,而且是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
-
协程: 协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
-
线程和协程的区别
- 线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这样代价就小了。
- 协程的切换时间点是由调度器决定,而不是由系统内核决定的,尽管它们的切换点都是时间片超过一定阈值,或者是进入I/O或睡眠等状态时。
- 基于垃圾回收的考虑,Go实现了垃圾回收,但垃圾回收的必要条件是内存位于一致状态,因此就需要暂停所有的线程。如果交给系统去做,那么会暂停所有的线程使其一致。对于Go语言来说,调度器知道什么时候内存位于一致状态,所以也就没有必要暂停所有运行的线程。
-
-
介绍一下Goroutine
Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。
goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。它在语言层面已经内置了调度和上下文切换的机制。
goroutine是Go并发设计的核心,也叫协程,它比线程更加轻量,因此可以同时运行成千上万个并发任务。在Go语言中,每一个并发的执行单元叫作一个goroutine。我们只需要在调用的函数前面添加go关键字,就能使这个函数以协程的方式运行。
-
context包结构原理和用途
Context(上下文)是Golang应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。
Context 只定义了接口,凡是实现该接口的类都可称为是一种 context。
- 「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
- 「Done」 方法:返回一个只读的 channel ,类型为 struct{}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
- 「Err」 方法:返回Context 被取消的原因。
- 「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。
-
goroutine调度
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
-
- G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
-
- P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
-
- M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;
P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。
P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。
-
-
如何避免Goroutine泄露和泄露场景
gorouinte 里有关于 channel 的操作,如果没有正确处理 channel 的读取,会导致 channel 一直阻塞住, goroutine 不能正常结束
-
waitgroup 用法和原理
waitgroup 内部维护了一个计数器,当调用
wg.Add(1)
方法时,就会增加对应的数量;当调用wg.Done()
时,计数器就会减一。直到计数器的数量减到 0 时,就会调用
runtime_Semrelease 唤起之前因为wg.Wait()
而阻塞住的 goroutine。使用方法:
- main 协程通过调用 wg.Add(delta int) 设置 worker 协程的个数,然后创建 worker 协程;
- worker 协程执行结束以后,都要调用 wg.Done();
- main 协程调用 wg.Wait() 且被 block,直到所有 worker 协程全部执行结束后返回。
实现原理:
-
WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低 32bit。
-
每次 Add 执行,请求计数器 v 加 1,Done 方法执行,请求计数器减 1,v为0 时通过信号量唤醒 Wait()。
1.5 GMP调度
-
GMP是什么
- G(Goroutine):即Go协程,每个go关键字都会创建一个协程。
- M(Machine):工作线程,在Go中称为Machine,数量对应真实的CPU数(真正干活的对象)。
- P(Processor):处理器(Go中定义的一个摡念,非CPU),包含运行Go代码的必要资源,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS() 来设置,默认为核心数。
M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。
优先从 P 的本地队列获取 goroutine 来执行;如果本地队列没有,从全局队列获取,如果全局队列也没有,会从其他的 P 上偷取 goroutine。
-
GMP goroutine调度策略
- 队列轮转:P 会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将G放到队列尾部,然后从队列中再取出一个G进行调度。除此之外,P还会周期性的查看全局队列是否有G等待调度到M中执行。
- 系统调用:当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。M1的来源有可能是M的缓存池,也可能是新建的。
- 当G0系统调用结束后,如果有空闲的P,则获取一个P,继续执行G0。如果没有,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
-
调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
- work stealing 机制
- 当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。
- hand off 机制
- 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。
抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
全局 G 队列:,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。
- work stealing 机制
-
CSP 模型是“以通信的方式来共享内存”,不同于传统的多线程通过共享内存来通信。用于描述两个独立的并发实体通过共享的通讯 channel (管道)进行通信的并发模型。
-
两种抢占式调度
协作式的抢占式调度
在 1.14 版本之前,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度,存在问题
-
某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿
-
垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作。
基于信号的抢占式调度
在任何情况下,Go 运行时并行执行(注意,不是并发)的 goroutines 数量是小于等于 P 的数量的。为了提高系统的性能,P 的数量肯定不是越小越好,所以官方默认值就是 CPU 的核心数,设置的过小的话,如果一个持有 P 的 M, 由于 P 当前执行的 G 调用了 syscall 而导致 M 被阻塞,那么此时关键点:GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以后,才会发现有一个 P/M 被 syscall 阻塞了。然后,才会用空闲的 M 来强这个 P。通过 sysmon 监控实现的抢占式调度,最快在 20us,最慢在 10-20ms 才 会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调度(一般情况 1ms 可以完成几十次线程调度),Go 发起 IO/syscall 的时候执行该 G 的 M 会阻塞然后被 OS 调度走,P 什么也不干,sysmon 最慢要 10-20ms才能发现这个阻塞,说不定那时候阻塞已经结束了,宝贵的 P 资源就这么被阻塞的 M 浪费。
-
-
GMP 调度过程中存在哪些阻塞
-
I/O,select
-
block on syscall
-
channel
-
等待锁
-
runtime.Gosched()
-
-
GMP 调度流程
-
每个 P 有个局部队列,局部队列保存待执行的 goroutine(流程 2),当 M 绑 定的 P 的的局部队列已经满了之后就会把 goroutine 放到全局队列(流程 2- 1)
-
每个 P 和一个 M 绑定,M 是真正的执行 P 中 goroutine 的实体(流程 3),M 从绑定的 P 中的局部队列获取 G 来执行
-
当 M 绑定的 P 的局部队列为空时,M 会从全局队列获取到本地队列来执行G(流程 3.1),当从全局队列中没有获取到可执行的 G 时候,M 会从其他 P 的局部队列中偷取 G 来执行(流程 3.2),这种从其他 P 偷的方式称为 work stealing
-
当 G 因系统调用(syscall)阻塞时会阻塞 M,此时 P 会和 M 解绑即 hand off,并寻找新的 idle 的 M,若没有 idle 的 M 就会新建一个 M(流程 5.1)。
-
当 G 因 channel 或者 network I/O 阻塞时,不会阻塞 M,M 会寻找其他 runnable 的 G;当阻塞的 G 恢复后会重新进入 runnable 进入 P 队列等待执 行(流程 5.3)
-
1.6 垃圾回收机制
-
GC 原理
垃圾回收就是对程序中不再使用的内存资源进行自动回收的操作。
三色标记法
- 初始状态下所有对象都是白色的。
- 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
- 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
- 循环步骤3,直到灰色对象全部变黑色。
- 通过写屏障(write-barrier)检测对象有变化,重复以上操作
- 收集所有白色对象(垃圾)。
STW(Stop The World)
- 为了避免在 GC 的过程中,对象之间的引用关系发生新的变更,使得GC的结果发生错误(如GC过程中新增了一个引用,但是由于未扫描到该引用导致将被引用的对象清除了),停止所有正在运行的协程。
- STW对性能有一些影响,Golang目前已经可以做到1ms以下的STW。
-
GC 的触发条件
主动触发(手动触发),通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕。
被动触发,分为两种方式:- 使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC。
- 使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。
-
Golang为什么小对象多了会造成gc压力
通常小对象过多会导致GC三色法消耗过多的GPU。优化思路是,减少对象分配。
-
GC的屏障介绍
写屏障(Write Barrier)
- 为了避免GC的过程中新修改的引用关系到GC的结果发生错误,我们需要进行STW。但是STW会影响程序的性能,所以我们要通过写屏障技术尽可能地缩短STW的时间。
写屏障:并发gc会产生黑色节点引用白色节点情况,导致正常的指针变量错误的被清除;解决方法为写屏障;
主要包括强三色不变式和弱三色不变式;
强三色不变:黑色节点不能引用白色节点,如果引用白色节点需要将白色节点置灰(插入写屏障);
弱三色不变:黑节点可以引用白节点,但白节点有其他灰色节点或递归指向存在灰色节点,删除白色节点引用时,需要把白色节点置灰(删除写屏障);
栈上变量较小,且频繁开辟或删除,不开启写屏障;需要之后一次rescan;
stw时机:
插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;(1.5版本采用) 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象;
混合写屏障:1.8版本加入
原因是stw需要耗时;加入混合写屏障,解决这个问题;
流程:
1.开始标记时候,栈上可达节点均置黑,之后不进行rescan,不用stw;
2.gc时产生的在栈上创建的对象,均置黑;
3.堆空间删除的对象置灰;
4.堆空间插入的对象置灰;
特点
-
混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫描垃圾即可;
-
混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,GC 期间,任何在栈上创建的新对象,均为黑色。扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;
-
混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的是 GC 过程全程无 STW;
-
混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是要停止这个 goroutine 赋值器的工作的哈(针对一个 goroutine 栈来说,是暂停扫的,要么全灰,要么全黑哈,原子状态切换)。
-
GC 的流程是什么
当前版本的 Go 以 STW 为界限,可以将 GC 划分为五个阶段:
阶段说明赋值器状态 GCMark 标记准备阶段,为并发标记做准备工作,启动写屏障 STWGCMark 扫描标记阶段,与赋值器并发执行,写屏障开启并发
GCMarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 STWGCoff 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭并发
GCoff 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭并发
-
GC 如何调优
优化内存的申请速度,尽可能少申请内存,复用已申请的内存。三个关键字:控制、减少、复用。
通过 go tool pprof 和 go tool trace 等工具
- 控制内存分配的速度,限制 goroutine 的数量,从而提高赋值器对 CPU 的利用率。
- 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。
- 需要时,增大 GOGC 的值,降低 GC 的运行频率。
1.7 其他知识点
-
new和make的区别
- make 仅用来分配及初始化类型为 slice、map、chan 的数据。
- new 可分配任意类型的数据,根据传入的类型申请一块内存,返回指向这块内存的指针,即类型 *Type。
- make 返回引用,即 Type,new 分配的空间被清零, make 分配空间后,会进行初始。
-
go的内存分配是怎么样的
Go 的内存分配借鉴了 Google 的 TCMalloc 分配算法,其核心思想是内存池 + 多级对象管理。内存池主要是预先分配内存,减少向系统申请的频率;多级对象有:mheap、mspan、arenas、mcentral、mcache。它们以 mspan 作为基本分配单位。具体的分配逻辑如下:
- 当要分配大于 32K 的对象时,从 mheap 分配。
- 当要分配的对象小于等于 32K 大于 16B 时,从 P 上的 mcache 分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。
- 当要分配的对象小于等于 16B 时,从 mcache 上的微型分配器上分配。
-
竞态、内存逃逸
竞态
资源竞争,就是在程序中,同一块内存同时被多个 goroutine 访问。我们使用 go build、go run、go test 命令时,添加 -race 标识可以检查代码中是否存在资源竞争。
解决这个问题,我们可以给资源进行加锁,让其在同一时刻只能被一个协程来操作。
- sync.Mutex
- sync.RWMutex
逃逸分析
「逃逸分析」就是程序运行时内存的分配位置(栈或堆),是由编译器来确定的。堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。
在 Go 里变量的内存分配方式则是由编译器来决定的。如果变量在作用域(比如函数范围)之外,还会被引用的话,那么称之为发生了逃逸行为,此时将会把对象放到堆上,即使声明为值类型;如果没有发生逃逸行为的话,则会被分配到栈上,即使 new 了一个对象。
逃逸场景:
- 指针逃逸
- 栈空间不足逃逸
- 动态类型逃逸
- 闭包引用对象逃逸
-
什么是 rune 类型
rune 类型,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型等价于 int32 类型。
-
go语言触发异常的场景有哪些
-
空指针解析
-
下标越界
-
除数为0
-
调用panic函数
-
-
go的接口
Go 语言提供了另外一种数据类型即接口,它把所有的具有共性的方法定义在一起,任何其他类型只要实现了这些方法就是实现了这个接口。
接口可以让我们将不同的类型绑定到一组公共的方法上,从而实现多态和灵活的设计。
Go 语言中的接口是隐式实现的,也就是说,如果一个类型实现了一个接口定义的所有方法,那么它就自动地实现了该接口。因此,我们可以通过将接口作为参数来实现对不同类型的调用,从而实现多态。
-
相比较于其他语言, Go 有什么优势或者特点
-
Go代码的设计是务实的。每个功能和语法决策都旨在让程序员的生活更轻松。
-
Golang 针对并发进行了优化,并且在规模上运行良好。
-
由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。
-
自动垃圾收集明显比 Java 或 Python 更有效,因为它与程序同时执行。
-
-
defer、panic、recover 三者的用法
defer 函数调用的顺序是后进先出,当产生 panic 的时候,会先执行 panic 前面的 defer 函数后才真的抛出异常。一般的,recover 会在 defer 函数里执行并捕获异常,防止程序崩溃。
-
Go反射
介绍
Go语言提供了一种机制在运行时更新和检查变量的值、调用变量的方法和变量支持的内在操作,但是在编译时并不知道这些变量的具体类型,这种机制被称为反射。反射也可以让我们将类型本身作为第一类的值类型处理。
反射是指在程序运行期对程序本身进行访问和修改的能力,程序在编译时变量被转换为内存地址,变量名不会被编译器写入到可执行部分,在运行程序时程序无法获取自身的信息。
Go语言中的反射是由 reflect 包提供支持的,它定义了两个重要的类型 Type 和 Value 任意接口值在反射中都可以理解为由 reflect.Type 和 reflect.Value 两部分组成,并且 reflect 包提供了 reflect.TypeOf 和 reflect.ValueOf 两个函数来获取任意对象的 Value 和 Type。
反射三定律
- 反射第一定律:反射可以将interface类型变量转换成反射对象
- 反射第二定律:反射可以将反射对象还原成interface对象
- 反射第三定律:反射对象可修改,value值必须是可设置的
-
Go语言函数传参是值类型还是引用类型
- 在Go语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。
- 另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。
-
Go语言中的内存对齐
CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。
CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数,例如:
变量 a、b 各占据 3 字节的空间,内存对齐后,a、b 占据 4 字节空间,CPU 读取 b 变量的值只需要进行一次内存访问。如果不进行内存对齐,CPU 读取 b 变量的值需要进行 2 次内存访问。第一次访问得到 b 变量的第 1 个字节,第二次访问得到 b 变量的后两个字节。
内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后,对该变量的访问就是原子的,这个特性在并发场景下至关重要。
简言之:合理的内存对齐可以提高内存读写的性能,并且便于实现变量操作的原子性。
-
空 struct{} 的用途
因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。
- 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
- 不发送数据的信道(channel)
使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。 - 结构体只包含方法,不包含任何的字段
-
值传递和地址传递(引用传递)
Go 语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。因为拷贝的内容有时候是非引用类型(int、string、struct 等这些),这样就在函数中就无法修改原内容数据;有的是引用类型(指针、map、slice、chan等 这些),这样就可以修改原内容数据。
Golang 的引用类型包括 slice、map 和 channel。它们有复杂的内部结构,除了申请内存外,还需要初始化相关属性。内置函数 new 计算类型大小,为其分配零值内存,返回指针。而 make 会被编译器翻译成具体的创建函数,由其分 配内存和初始化成员结构,返回对象而非指针。
-
原子操作
一个或者多个操作在 CPU 执行过程中不被中断的特性,称为原子性 (atomicity)。
这些操作对外表现成一个不可分割的整体,他们要么都执行,要么都不执行,外界不会看到他们只执行到一半的状态。而在现实世界中,CPU不可能不中断的执行一系列操作,但如果我们在执行多个操作时,能让他们的中间状态对外不可见,那我们就可以宣城他们拥有了“不可分割”的原子性。
在 Go 中,一条普通的赋值语句其实不是一个原子操作。列如,在 32 位机器上写 int64 类型的变量就会有中间状态,因为他会被拆成两次写操作(MOV)——写低 32 位和写高 32 位。
2. Web框架Gin和微服务框架Micro
2.1 Gin框架
-
什么是Gin框架
Gin是一个用Go语言编写的web框架,,优点是封装比较好,API友好,源码注释比较明确,具有快速灵活,容错方便等特点。它具有运行速度快,分组的路由器,良好的崩溃捕获和错误处理,非常好的支持中间件和 json。
-
Gin路由的实现
gin框架使用的是定制版本的httprouter,其路由的原理是大量使用公共前缀的树结构,它基本上是一个紧凑的Trie tree(或者只是Radix Tree)。具有公共前缀的节点也共享一个公共父节点。
gin框架使用的是定制版本的httprouter,其路由的原理是大量使用公共前缀的树结构,它基本上是一个紧凑的Trie tree(或者只是Radix Tree)。具有公共前缀的节点也共享一个公共父节点。
路由树是由一个个节点构成的,gin框架路由树的节点由
node
结构体表示在gin的路由中,每一个
HTTP Method
(GET、POST、PUT、DELETE…)都对应了一棵radix tree
,我们注册路由的时候会调用addRoute`函数注册路由的逻辑主要有
addRoute
函数和insertChild
方法。? 路由树构造的详细过程:
- 第一次注册路由,例如注册search
- 继续注册一条没有公共前缀的路由,例如blog
- 注册一条与先前注册的路由有公共前缀的路由,例如support
路由树构造的详细过程:
- 第一次注册路由,例如注册search
- 继续注册一条没有公共前缀的路由,例如blog
- 注册一条与先前注册的路由有公共前缀的路由,例如support
路由匹配是由节点的
getValue
方法实现的。getValue
根据给定的路径(键)返回nodeValue
值,保存注册的处理函数和匹配到的路径参数数据。gin框架路由使用前缀树,路由注册的过程是构造前缀树的过程,路由匹配的过程就是查找前缀树的过程。
-
Gin的中间件
Gin框架允许开发者在处理请求的过程中,加入用户自己的钩子(Hook)函数。这个钩子函数就叫中间件,中间件适合处理一些公共的业务逻辑,比如登录认证、权限校验、数据分页、记录日志、耗时统计等。
gin框架涉及中间件相关有4个常用的方法,它们分别是
c.Next()
、c.Abort()
、c.Set()
、c.Get()
。gin框架的中间件函数和处理函数是以切片形式的调用链条存在的,我们可以顺序调用也可以借助
c.Next()
方法实现嵌套调用。借助
c.Set()
和c.Get()
方法我们能够在不同的中间件函数中传递数据。gin默认中间件
gin.Default()默认使用了Logger和Recovery中间件,其中:Logger中间件将日志写入gin.DefaultWriter,即使配置了GIN_MODE=release。
Recovery中间件会recover任何panic。如果有panic的话,会写入500响应码。
如果不想使用上面两个默认的中间件,可以使用gin.New()新建一个没有任何默认中间件的路由。
2.2 Micro框架
-
对微服务的了解
使用一套小服务来开发单个应用的方式,每个服务运行在独立的进程里,一般采用轻量级的通讯机制互联,并且它们可以通过自动化的方式部署
微服务特点
- 单一职责,此时项目专注于登录和注册
- 轻量级的通信,通信与平台和语言无关,http是轻量的
- 隔离性,数据隔离
- 有自己的数据
- 技术多样性
-
微服务架构的优势和缺点
优点
1、易于开发和维护
2、启动较快
3、局部修改容易部署
4、技术栈不受限
5、按需伸缩
缺点
1、运维要求较高
2、分布式的复杂性
3、接口调整成本高
4、重复劳动
-
RPC协议
- 远程过程调用(Remote Procedure Call,RPC)是一个计算机通信协议
- 该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程
- 如果涉及的软件采用面向对象编程,那么远程过程调用亦可称作远程调用或远程方法调用
RPC调用流程
微服务架构下数据交互一般是对内 RPC,对外 REST
将业务按功能模块拆分到各个微服务,具有提高项目协作效率、降低模块耦合度、提高系统可用性等优点,但是开发门槛比较高,比如 RPC 框架的使用、后期的服务监控等工作
一般情况下,我们会将功能代码在本地直接调用,微服务架构下,我们需要将这个函数作为单独的服务运行,客户端通过网络调用流行RPC框架:Dubbo、Motan、Thrift、gRPC
-
gRPC介绍
gRPC由google开发,是一款语言中立、平台中立、开源的远程过程调用系统
gRPC 是一个高性能、开源、通用的RPC框架,基于HTTP2协议标准设计开发,默认采用Protocol Buffers数据序列化协议,支持多种开发语言。gRPC提供了一种简单的方法来精确的定义服务,并且为客户端和服务端自动生成可靠的功能库。
在gRPC客户端可以直接调用不同服务器上的远程程序,使用起来就像调用本地程序一样,很容易去构建分布式应用和服务。和很多RPC系统一样,服务端负责实现定义好的接口并处理客户端的请求,客户端根据接口描述直接调用需要的服务。客户端和服务端可以分别使用gRPC支持的不同语言实现。
gRPC主要特性
- 强大的IDL
gRPC使用ProtoBuf来定义服务,ProtoBuf是由Google开发的一种数据序列化协议(类似于XML、JSON、hessian)。ProtoBuf能够将数据进行序列化,并广泛应用在数据存储、通信协议等方面。
- 多语言支持
gRPC支持多种语言,并能够基于语言自动生成客户端和服务端功能库。目前已提供了C版本grpc、Java版本grpc-java 和 Go版本grpc-go,其它语言的版本正在积极开发中,其中,grpc支持C、C++、Node.js、Python、Ruby、Objective-C、PHP和C等语言,grpc-java已经支持Android开发。
- HTTP2
gRPC基于HTTP2标准设计,所以相对于其他RPC框架,gRPC带来了更多强大功能,如双向流、头部压缩、多复用请求等。这些功能给移动设备带来重大益处,如节省带宽、降低TCP链接次数、节省CPU使用和延长电池寿命等。同时,gRPC还能够提高了云端服务和Web应用的性能。gRPC既能够在客户端应用,也能够在服务器端应用,从而以透明的方式实现客户端和服务器端的通信和简化通信系统的构建。
-
Protobuf介绍
Google Protocol Buffer( 简称 Protobuf) 是 Google 公司内部的混合语言数据标准,目前已经正在使用的有超过 48,162 种报文格式定义和超过 12,183 个 .proto 文件。他们用于 RPC 系统和持续数据存储系统。Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化、或者说序列化。它很适合做数据存储或RPC数据交换格式。可以用于即时通讯、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式
protobuf的核心内容包括:
-
定义消息:消息的结构体,以message标识。
-
定义接口:接口路径和参数,以service标识。
通过protobuf提供的机制,服务端与服务端之间只需要关注接口方法名(service)和参数(message)即可通信,不需关注繁琐的链路协议和字段解析,极大降低服务端的设计开发成本。
-
-
Micro介绍和主要功能
go-micro简介
- Go Micro是一个插件化的基础框架,基于此可以构建微服务,Micro的设计哲学是可插拔的插件化架构
- 在架构之外,它默认实现了consul作为服务发现,通过http进行通信,通过protobuf和json进行编解码
- 是用来构建和管理分布式程序的系统
- Runtime (运行时) : 用来管理配置,认证,网络等
- Framework (程序开发框架) : 用来方便编写微服务
- Clients (多语言客户端) : 支持多语言访问服务端
go-micro的主要功能
-
服务发现:自动服务注册和名称解析。
-
负载均衡:基于服务发现构建的客户端负载均衡。
-
消息编码:基于内容类型的动态消息编码。
-
请求/响应:基于RPC的请求/响应,支持双向流。
-
Async Messaging:PubSub是异步通信和事件驱动架构的一流公民。
-
可插拔接口:Go Micro为每个分布式系统抽象使用Go接口,因此,这些接口是可插拔的,并允许Go Micro与运行时无关,可以插入任何基础技术
go-micro特性
- api: api 网关。使用服务发现具有动态请求路由的单个入口点. API 网关允许您在后端构建可扩展的微服务体系结构,并在前端合并公共 api. micro api 通过发现和可插拔处理程序提供强大的路由,为 http, grpc, Websocket, 发布事件等提供服务.
- broker: 允许异步消息的消息代理。微服务是事件驱动的体系结构,应该作为一等公民提供消息传递。通知其他服务的事件,而无需担心响应.
- network: 通过微网络服务构建多云网络。只需跨任何环境连接网络服务,创建单个平面网络即可全局路由. Micro 的网络根据每个数据中心中的本地注册表动态构建路由,确保根据本地设置路由查询.
- new: 服务模板生成器。创建新的服务模板以快速入门. Micro 提供用于编写微服务的预定义模板。始终以相同的方式启动,构建相同的服务以提高工作效率.
- proxy: 建立在 Go Micro 上的透明服务代理。将服务发现,负载平衡,容错,消息编码,中间件,监视等卸载到单个位置。独立运行它或与服务一起运行.
- registry: 注册表提供服务发现以查找其他服务,存储功能丰富的元数据和终结点信息。它是一个服务资源管理器,允许您在运行时集中和动态地存储此信息.
- store: 有状态是任何系统的必然需求。我们提供密钥值存储,提供简单的状态存储,可在服务之间共享或长期卸载 m 以保持微服务无状态和水平可扩展.
- web: Web 仪表板允许您浏览服务,描述其终结点,请求和响应格式,甚至直接查询它们。仪表板还包括内置 CLI 的体验,适用于希望动态进入终端的开发人员.
-
Micro通信流程
Server监听客户端的调用,和Brocker推送过来的信息进行处理。并且Server端需要向Register注册自己的存在或消亡,这样Client才能知道自己的状态
Register服务的注册的发现,Client端从Register中得到Server的信息,然后每次调用都根据算法选择一个的Server进行通信,当然通信是要经过编码/解码,选择传输协议等一系列过程的
如果有需要通知所有的Server端可以使用Brocker进行信息的推送,Brocker 信息队列进行信息的接收和发布 -
consul
Consul是用于实现分布式系统的服务发现与配置,Consul是分布式的、高可用的、可横向扩展的。
注册中心Consul关键功能
服务发现:客户端可以注册服务,程序可以轻松找到它们所依赖的服务
运行状况检查:Consul客户端可以提供任意数量的运行状况检查
KV 存储:应用程序可以将Consul的层级键/值存储用于任何目的,包括动态配置,功能标记,协调,领导者选举等
安全服务通信:Consul 可以为服务生成和分发TLS证书,建立相互的TLS连接
多数据中心:Consul 支持多个数据中心
注册中心Consul两个重要协议-
Gossip Protocol (八卦协议)
-
Raft Protocol ( 选举协议)
-
-
Jaeger
什么是链路追踪:
分布式链路追踪就是将一次分布式请求还原成调用链路,将一次分布式请求的调用情况集中展示,比如各个服务节点上的耗时、请求具体到达哪台机器上、每个服务节点的请求状态等等
链路追踪主要功能:-
故障快速定位:可以通过调用链结合业务日志快速定位错误信息
-
链路性能可视化:各个阶段链路耗时、服务依赖关系可以通过可视化界面展现出来
-
链路分析:通过分析链路耗时、服务依赖关系可以得到用户的行为路径,汇总分析应用在很多业务场景
jaeger链路追踪作用
- 它是用来监视和诊断基于微服务的分布式系统
- 用于服务依赖性分析,辅助性能优化
Jaeger组成
Jaeger Client - 为不同语言实现了符合 OpenTracing 标准的 SDK。应用程序通过 API 写入数据,client library 把 trace 信息按照应用程序指定的采样策略传递给 jaeger-agent。
Agent - 它是一个监听在 UDP 端口上接收 span 数据的网络守护进程,它会将数据批量发送给 collector。它被设计成一个基础组件,部署到所有的宿主机上。Agent 将 client library 和 collector 解耦,为 client library 屏蔽了路由和发现 collector 的细节。
Collector - 接收 jaeger-agent 发送来的数据,然后将数据写入后端存储。Collector 被设计成无状态的组件,因此您可以同时运行任意数量的 jaeger-collector。
Data Store - 后端存储被设计成一个可插拔的组件,支持将数据写入 cassandra、elastic search。
Query - 接收查询请求,然后从后端存储系统中检索 trace 并通过 UI 进行展示。Query 是无状态的,您可以启动多个实例,把它们部署在 nginx 这样的负载均衡器后面。
分布式追踪系统发展很快,种类繁多,但核心步骤一般有三个:代码埋点,数据存储、查询展示
-
-
Prometheus
promethues介绍
promethues工作流程
-
Prometheus server定期从配置好的jobs/exporters/Pushgateway中拉数据
-
Prometheus server记录数据并且根据报警规则推送alert数据
-
Alertmanager 根据配置文件,对接收到的警报进行处理,发出告警。
-
在图形界面中,可视化采集数据
promethues重要组件
-
Prometheus Server:用于收集和存储时间序列数据。
-
Client Library:客户端库成相应的metrics并暴露给Prometheus server
-
Push Gateway:主要用于短期的jobs
-
Exporters: 用于暴露已有的第三方服务的metrics给Prometheus
-
Alertmanager: 从Prometheus server端接收到alerts后,会进行
grafana看板
- 拥有 丰富dashboard和图表编辑的指标分析平台
- 拥有自己的权限管理和用户管理系统
- Grafana 更适合用于数据可视化展示
-
-
熔断降级、限流、负载均衡
-
熔断降级
服务熔断也称服务隔离或过载保护。在微服务应用中,服务存在一定的依赖关系,形成一定的依赖链,如果某个目标服务调用慢或者有大量超时,造成服务不可用,间接导致其他的依赖服务不可用,最严重的可能会阻塞整条依赖链,最终导致业务系统崩溃(又称雪崩效应)。此时,对该服务的调用执行熔断,对于后续请求,不再继续调用该目标服务,而是直接返回,从而可以快速释放资源。等到目标服务情况好转后,则可恢复其调用。
关闭 (Closed):在这种状态下,我们需要一个计数器来记录调用失败的次数和总的请求次数,如果在某个时间窗口内,失败的失败率达到预设的阈值,则切换到断开状态,此时开启一个超时时间,当到达该时间则切换到半关闭状态,该超时时间是给了系统一次机会来修正导致调用失败的错误,以回到正常的工作状态。在关闭状态下,调用错误是基于时间的,在特定的时间间隔内会重置,这能够防止偶然错误导致熔断器进去断开状态
打开 (Open):在该状态下,发起请求时会立即返回错误,一般会启动一个超时计时器,当计时器超时后,状态切换到半打开状态,也可以设置一个定时器,定期的探测服务是否恢复
半打开 (Half-Open):在该状态下,允许应用程序一定数量的请求发往被调用服务,如果这些调用正常,那么可以认为被调用服务已经恢复正常,此时熔断器切换到关闭状态,同时需要重置计数。如果这部分仍有调用失败的情况,则认为被调用方仍然没有恢复,熔断器会切换到关闭状态,然后重置计数器,半打开状态能够有效防止正在恢复中的服务被突然大量请求再次打垮。
常见的有三种熔断降级策略
- 错误比例:在所设定的时间窗口内,调用的访问错误比例大于所设置的阈值,则对接下来访问的请求进行自动熔断。
- 错误计数:在所设定的时间窗口内,调用的访问错误次数大于所设置的阈值,则对接下来访问的请求进行自动熔断。
- 慢调用比例:在所设定的时间窗口内,慢调用的比例大于所设置的阈值,则对接下来访问的请求进行自动熔断。
服务降级
当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,增加响应速度。
关于降级,这里有两种场景:
- 当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,增加响应速度!
- 当下游的服务因为某种原因不可用,上游主动调用本地的一些降级逻辑,避免卡顿,迅速返回给用户!
-
限流
在微服务架构下,若大量请求超过微服务的处理能力时,可能会将服务打跨,甚至产生雪崩效应、影响系统的整体稳定性。比如说你的用户服务处理能力是1w/s,现在因为异常流量或其他原因,有10w的并发请求访问你的服务,那你的服务肯定扛不住啊。这种情况下,我们可以在流量超出承受阈值时,直接进行”限流”、拒绝部分请求,从而保证系统的整体稳定性。
限流算法
固定时间窗口
基于固定时间窗口的限流算法是非常简单的。首先需要选定一个时间起点,之后每次接口请求到来都累加计数器,如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次接口请求),累加访问次数超过限流值,则限流熔断拒绝接口请求。当进入下一个时间窗口之后,计数器清零重新计数。
滑动时间窗口算法
滑动时间窗口算法是对固定时间窗口算法的一种改进,流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。
漏桶和令牌桶算法
漏桶算法(Leaky Bucket):主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。漏桶算法提供了一种机制,通过它,突发流量可以被整形以便为网络提供一个稳定的流量。
请求先进入到漏桶里,漏桶以一定的速度出水,当水请求过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
令牌桶算法(Token Bucket):是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。
大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。
漏桶和令牌桶算法的区别
令牌桶算法,主要放在服务端,用来保护服务端(自己),主要用来对调用者频率进行限流,为的是不让自己被压垮。所以如果自己本身有处理能力的时候,如果流量突发(实际消费能力强于配置的流量限制=桶大小),那么实际处理速率可以超过配置的限制(桶大小)。
而漏桶算法,主要放在调用方,这是用来保护他人,也就是保护他所调用的系统。主要场景是,当调用的第三方系统本身没有保护机制,或者有流量限制的时候,我们的调用速度不能超过他的限制,由于我们不能更改第三方系统,所以只有在主调方控制。这个时候,即使流量突发,也必须舍弃。因为消费能力是第三方决定的。自适应限流
一般的限流常常需要指定一个固定值(qps)作为限流开关的阈值,这个值一是靠经验判断,二是靠通过大量的测试数据得出。但这个阈值,在流量激增、系统自动伸缩或者某某commit了一段有毒代码后就有可能变得不那么合适了。并且一般业务方也不太能够正确评估自己的容量,去设置一个合适的限流阈值。那么我们就可以考虑用自适应限流来解决这个问题。
对于自适应限流来说, 一般都是结合系统的 Load、CPU 使用率以及应用的入口 QPS、平均响应时间和并发量等几个维度的监控指标,通过自适应的流控策略, 让系统的入口流量和系统的负载达到一个平衡,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定。
分布式限流
上面使用的限流算法,都是基本单节点限流的。但线上业务出于各种原因考虑,多是分布式系统,单节点的限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。比如说我希望某个接口的QPS的1000次/秒,服务部署在5台机器上,虽然我们可以通过配置每台节点200次/秒来限流。但如果节点收缩或者扩容,那么久不能满足需求了。而且不同服务的物理配置不一定相同,可能有些节点处理得比较快,那么配置均值来限流,就不是一个好方法了。
常见的分布式限流策略
网关层限流:将限流规则应用在所有流量的入口处,比如nigix+lua
中间件限流:将限流信息存储在分布式环境中某个中间件里(比如Redis缓存),每个组件都可以从这里获取到当前时刻的流量统计,从而决定是拒绝服务还是放行流量。 -
负载均衡
Load balancing,即负载均衡,是一种计算机技术,用来在多个计算机(计算机集群)、网络连接、CPU、磁盘驱动器或其他资源中分配负载,以达到最优化资源使用、最大化吞吐率、最小化响应时间、同时避免过载的目的。
负载均衡(Load Balance),意思是将负载(工作任务,访问请求)进行平衡、分摊到多个操作单元(服务器,组件)上进行执行。是解决高性能,单点故障(高可用),扩展性(水平伸缩)的终极解决方案。
负载均衡算法
1、轮询法
将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。
2、随机法
通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配调用量到后端的每一台服务器,也就是轮询的结果。
3、源地址哈希法
源地址哈希的思想是根据获取客户端的IP地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一IP地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。
4、加权轮询法
不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。
5、加权随机法
与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。
6、最小连接数法
最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前。
积压连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用效率,将负责合理地分流到每一台服务器。
-
2.3 Viper
-
什么是Viper
Viper是适用于Go应用程序的完整配置解决方案。它被设计用于在应用程序中工作,并且可以处理所有类型的配置需求和格式。
特性:
- 设置默认值
- 从
JSON
、TOML
、YAML
、HCL
、envfile
和Java properties
格式的配置文件读取配置信息 - 实时监控和重新读取配置文件(可选)
- 从环境变量中读取
- 从远程配置系统(etcd或Consul)读取并监控配置变化
- 从命令行参数读取配置
- 从buffer读取配置
- 显式配置值
-
Viper支持什么功能
Viper能够为你执行下列操作:
- 查找、加载和反序列化
JSON
、TOML
、YAML
、HCL
、INI
、envfile
和Java properties
格式的配置文件。 - 提供一种机制为你的不同配置选项设置默认值。
- 提供一种机制来通过命令行参数覆盖指定选项的值。
- 提供别名系统,以便在不破坏现有代码的情况下轻松重命名参数。
- 当用户提供了与默认值相同的命令行或配置文件时,可以很容易地分辨出它们之间的区别。
- 建立默认值
- 读取配置文件
- 写入配置文件
- 监控并重新读取配置文件
- 从io.Reader读取配置
- 覆盖设置
- 注册和使用别名
- 使用环境变量
- 使用Flags
- 远程Key/Value存储支持
- 监控etcd中的更改-未加密
- 查找、加载和反序列化
2.4 Swagger
-
什么是Swagger
Swagger本质上是一种用于描述使用JSON表示的RESTful API的接口描述语言。Swagger与一组开源软件工具一起使用,以设计、构建、记录和使用RESTful Web服务。Swagger包括自动文档,代码生成和测试用例生成。
想要使用
gin-swagger
为你的代码自动生成接口文档,一般需要下面三个步骤:- 按照swagger要求给接口代码添加声明式注释,具体参照声明式注释格式
- 使用swag工具扫描代码自动生成API接口文档数据
- 使用gin-swagger渲染在线接口文档页面
-
Swagger的优势
在前后端分离的项目开发过程中,如果后端同学能够提供一份清晰明了的接口文档,那么就能极大地提高大家的沟通效率和开发效率。可是编写接口文档历来都是令人头痛的,而且后续接口文档的维护也十分耗费精力。
最好是有一种方案能够既满足我们输出文档的需要又能随代码的变更自动更新,而Swagger正是那种能帮我们解决接口文档问题的工具。
2.5 Zap
-
什么是Zap
Zap是在 Go 中实现超快、结构化、分级的日志记录。
Zap日志能够提供下面这些功能:
-
1、能够将事件记录到文件中,也可以在应用控制台输出
-
2、日志切割-可以根据文件大小,时间或间隔来切割日志文件
-
3、支持不同的日志级别。例如 INFO、DEBUG、ERROR等
-
4、能够打印基本信息,如调用文件/函数名和行号,日志时间等。
zap的基本配置
Zap提供了两种类型的日志记录器—Sugared Logger 和 Logger 。在性能很好但不是很关键的上下文中,使用 SugaredLogger 。它比其他结构化日志记录包快4-10倍,并且支持结构化和printf风格的日志记录。
在每一微秒和每一次内存分配都很重要的上下文中,使用 Logger 。它甚至比 SugaredLogger 更快,内存分配次数也更少,但它只支持强类型的结构化日志记录。
这个日志程序中唯一缺少的就是日志切割归档功能。添加日志切割归档功能,我们将使用第三方库Lumberjack来实现。
-
2.6 JWT
-
什么是JWT
JWT 英文名是 Json Web Token ,是一种用于通信双方之间传递安全信息的简洁的、URL安全的表述性声明规范,经常用在跨域身份验证。
JWT 以 JSON 对象的形式安全传递信息。因为存在数字签名,因此所传递的信息是安全的。
一个JWT Token就像这样:
eyJhbGci0iJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyODAx0DcyNzQ40DMyMzU4NSwiZ
XhwIjoxNTk0NTQwMjkxLCJpc3MiOiJibHV1YmVsbCJ9.1k_ZrAtYGCeZhK3iupHxP1kgjBJzQTVTtX0iZYFx9wU -
JWT的实现
JWT由.分割的三部分组成,这三部分依次是:
- 头部(Header)
作用:记录令牌类型、签名算法等 例如: - 负载(Payload)
作用:携带一些用户信息 例如 - 签名(Signature)
作用:防止Token被篡改、确保安全性 例如 计算出来的签名,一个字符串
头部和负载以json形式存在,这就是JWT中的JSON,三部分的内容都分别单独经过了Base64编码,以.拼接成一个JWT Token。
- 头部(Header)
-
JWT的优势
JWT就是一种基于Token的轻量级认证模式,服务端认证通过后,会生成一个JSON对象,经过签名后得到一个Token(令牌)再发回给用户,用户后续请求只需要带上这个Token,服务端解密之后就能获取该用户的相关信息了。
JWT拥有基于Token的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用Cookie的浏览器环境中正常运行。
而JWT的最大优势是服务端不再需要存储Session,使得服务端认证鉴权业务可以方便扩展,避免存储Session所需要引入的Redis等组件,降低了系统架构复杂度。但这也是JWT最大的劣势,由于有效期存储在Token中,JWT Token-旦签发,就会在有效期内-直可用,无法在服务端废止,当用户进行登出操作,只能依赖客户端删除掉本地存储的JWT Token,如果需要禁用用户,单纯使用JWT就无法做到了。