Golang 八股文学习?

1.缓存

缓存击穿/缓存穿透/缓存雪崩

缓存击穿——本质:热点数据不在缓存。

缓存穿透——本质:请求的数据既不在缓存也不在数据库。

缓存雪崩——本质:大量的缓存数据同时过期/Redis 宕机。

大量的缓存数据同时过期:

原因: 缓存中的数据都是有过期时间的,数据一旦过期,业务系统重新生成缓存,因此访问数据库,并把数据库的数据更新到缓存中间。在大量数据过期的同时,如果同时收到很多的用户请求,那么这些请求直接打到数据库上,造成数据库宕机。

解决: 1-『均匀的给数据设置过期时间』给数据设置随机的、均匀的过期时间。

2-『对于想要访问数据库的请求加互斥锁+给锁设置超时』给(访问的数据不在缓存的)请求加互斥锁,使得仅仅只有一个请求来构建缓存。实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

3-『对缓存数据使用两个 key』一个是主 key,一个是备 key,备用的 key 不会设置过期时间,主 key 和备 key 之间只有 key 不一样,但是 value 是一样的,相当于给缓存的数据做了备份。业务线程访问不到『主 key』的缓存数据就直接返回『备 key』的数据,后续通知后台线程重新构建『主 key』和『备 key』的数据。

4- 『业务不更新缓存+后台更新缓存』(如果读取缓存失败就认为是数据丢失。让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。)(后台解决:后台负责不定时的更新缓存,也负责频繁的检测缓存是否有效,如果检测到缓存是效,那么可能是系统紧张被淘汰。于是马上读取数据库数据,更新缓存)(业务解决:如果发现缓存失效,那么就通过消息队列发送消息通知后台更新缓存。后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。)

在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。

Redis 宕机:

1-『服务熔断』暂停业务应用对缓存服务的访问,直接返回错误。 2-『请求限流』请求量达到一定数值,对其他的请求直接拒绝服务。等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。 3-『构建 Redis 集群』服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。

缓存击穿

小部分热点数据过期。 我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。 如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

可以发现缓存击穿跟缓存雪崩很相似,可以认为缓存击穿是缓存雪崩的一个子集。

预防措施:

  • 『加互斥锁』保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
  • 『不设置过期时间』由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

补充: singleflight 能将对同一个资源访问的多个请求合并为一个请求,常见的应用场景比如缓存击穿。具体实现是使用了 map 对同一个资源访问的请求进行去重,使用互斥锁让当个协程进入临界区后进行资源访 问,其他线程阻塞等待资源访问完后,共同拿到访问资源的结果返回。

缓存穿透

本质:访问不存的数据

当发生缓存击穿和缓存雪崩数据库还保留着数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。 当用户访问的数据,既不在缓存也不再数据库,导致请求在访问缓存的时候,发现缓存缺失,然后再去访问数据库,当大量这样的数据到来的时候,数据库的压力就会骤然增加。

缓存穿透的发生一般有这两种情况: 1.业务误操作 缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据; 2-黑客恶意攻击 故意大量访问某些读取不存在数据的业务;

应对缓存穿透的方案,常见的方案有三种。 1-非法请求直接返回。 非法恶意请求就直接返回错误,避免进一步访问缓存和数据库。 2-不存在的数据设置空值。 对于查询的数据,如果缓存中没有这个数据,那么就设置一个空值,后续同样的请求或者同样是访问(数据库和缓存不存在的)请求,就直接返回空值。 3-布隆过滤器,对请求判断数据是否存在,避免了查询数据库来判断数据是否存在。 具体是怎么操作?答:对于写入数据库的数据做个标记,然后在用户请求的时候,业务线程确认缓存失败,通过查询布隆过滤器判断数据在数据库是否存在。 即使发生缓存穿透,大量的请求只会查询 Redis 和布隆过滤器,而不会查询数据库。保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

那问题来了,布隆过滤器是如何工作的呢?接下来,我介绍下。

第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值; 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。

2.并发

Channel+goroutine

通过共享内存来通信

本质:多个线程,共享一个变量,通过各自都对这个变量加锁,来宣布自己占有使用它,通过释放锁来宣布不再使用它。

通过通信来共享内存

本质: 1- Channel 的使用会把使用者分为生产者和消费者 2- Channel 的本质是一个有锁的环型队列,包括发送方队列,接收方队列,互斥锁等结构。 3- Channel 的设计原则是:先向 Channel 发送数据 goroutine 先得到发送数据的权限,先从 Channel 接收数据 goroutine 先得到数据

Select

select 中的 case 中表达式必须是 channel 的收发操作,当 Select 中间的两个 case 同时被触发的时候,随机的执行其中的一个,随机的执行是为了避免饥饿的问题,如果每次都是按照顺序执行的,那么后面的语句就永远都不会被执行。select 的 default 语句是当不存在可以收发的 channel 的时候,执行 default 语句。

对已经关闭的 chan 进行读写,会怎么样?

已经关闭的 chan 能一直读东西,读到的内容根据通道内关闭前是否有元素而不同。 1- 如果 chan 关闭前,buffer 内有元素还未读,会正确读到 chan 值,且返回的第二个 bool 值为 true 2- 如果 chan 关闭前, buffer 内有元素已经被读完, chan 内无值,接下来所有接收的值都会非阻 塞直接成功,返回 channel 元素的零值,但是第二个 bool 值一直为 false 。 3- 写已经关闭的 chan 会 panic

3.golang 基础

切片和数组

1- golang 的数组是固定长度的,切片的本质是一个结构体,其中包含一个指针,这个指针指向底层的数组。 2- 拷贝大切片和小切片本质都是拷贝各自底层数组的指针,不同的是大切片的 len 比小切片的 len 大一点。 3- golang 只有值传递

深拷贝和浅拷贝

1- 使用=拷贝切片,是浅拷贝。 2- 使用[:]下标的方式复制切片也是浅拷贝。 3- 使用内置函数 copy 的方式进行切片拷贝,这种是深拷贝。

深浅拷贝都是进行复制,区别在于复制出来的新对象与原来的对象在它们发生改变时,是否会相互影响,本质区别就是复制出来的对象与原对象是否会指向同一个地址。 浅拷贝,复制出来的新的对象和原来的对象,没有关系,指向的底层数组是新的一块内存。深拷贝是指向同一块内存。

零切片,空切片,nil 切片。

1- 零切片,make([]int,10) 容量不为 0,长度不为 0,这个就是零值切片。『一个长度不为 0,容量不为 0,但是每个元素都是 nil 』 零切片:我们把切片内部数组的元素都是零值或者底层数组的内容全是 nil 的切片叫做零切片,使用 MAKE 创建的长度、容量都不为 0 的切片就是零值切片; 『零切片被分配了内存,且 len cap 不为 0』

2- 空切片 make([]int)容量和长度为 0,但是数据指针指向一个地址为 0xc42003BDA0 array := []int{} 『空切片被分配了内存,但是内存数据指针指向的内存地址是固定的,既所有的空切片的数据指针都指向同一个地址,且 len 和 cap 为 0,』

3- nil 切片 var array []int 『nil 切片没有被分配内存』

func Run() {
	var array1 []int
	fmt.Println(array1 == nil)
	array2 := []int{}
	fmt.Println(array2 == nil)
	fmt.Println(array2)
	array3 := new([]int)
	fmt.Println(array3 == nil)
	fmt.Println(array3)
}

切片的扩容策略

策略:当原切片容量小于 1024 的时候,新切片容量按照原来的 2 倍进行扩容,原 slice 容量超过 1024 的时候,新 slice 容量变成原来的 1.25 倍 实际如果经过内存对齐: 切片在扩容时会进行内存对齐,这个和内存分配策略有关,进行内存对齐后切片扩容的容量要大于等于 旧的切片容量的 2 倍或者 1.25 倍。

参数传递切片和切片指针有什么区别?

参数传递『切片』是『把被传递的切片的副本传进去』,对副本的赋值不会影响原来的切片,对副本的某个具体的元素的改变,影响到原切片 参数传递『切片指针』是『把被传递的切片指针的副本传进去』虽然传进去的也是副本,但是因为是指针,根据这个指针副本也能找到原来的切片,从而对切片进行更改。 本质: array[0] = 1这个操作没有影响切片底层数组的起始指针,但是 array = append(array, 4),相当于是更改了副本切片底层的数组的起始指针。使得切片和切片副本底层数组的指针不同。

func Run() {
	array := make([]int, 4)
	array[0] = 0
	array[1] = 1
	array[2] = 2
	array[3] = 3
	Change(array)
	fmt.Println(array)
}

func Change(array []int) {
	array[0] = 1
}
// 结果:[1 1 2 3]
func Run() {
	array := make([]int, 4)
	array[0] = 0
	array[1] = 1
	array[2] = 2
	array[3] = 3
	AppendChange(array)
	fmt.Println(array)

}

func AppendChange(array []int) {
	array = append(array, 4)
}
// 结果[0 1 2 3]
func Run() {
	array := make([]int, 4)
	array[0] = 0
	array[1] = 1
	array[2] = 2
	array[3] = 3
	AppendChangeAndChange(array)
	fmt.Println(array)

}
func AppendChangeAndChange(array []int) {
	array[0] = 1
	array = append(array, 4)
}
// 结果[1 1 2 3]

for range 遍历切片有什么要注意的吗?

func Run() {
	array := make([]int, 4)
	array[0] = 0
	array[1] = 1
	array[2] = 2
	array[3] = 3
	for k, v := range array {
		if k == 0 {
			array = append(array, 4)
		}
        if k==1{
            array[2]=7
        }
		fmt.Println(v)
	}
	fmt.Println(array)
}

/*

results are
0
1
2
3
[0 1 2 3 4] */

数组和切片的面试总结和性能提升点

『1』

func main() {
    nums1 := [3]int{1, 2, 3}
    nums2 := nums1
    nums2[0] = 10
    fmt.Println(nums1[0])
    nums3 := []int{1, 2, 3}
    nums4 := nums3
    nums4[0] = 10
    fmt.Println(nums3[0])
}
// 1  10

因为 nums1 和 nums3 的数据类型是不同的,nums1 的数据类型为数组,而 nums3 的数据类型是切片。 同样是用等号进行赋值,对于数组来说进行的是深拷贝(值拷贝),而切片则是浅拷贝(指针拷贝)。因此对 nums2 的赋值改变的是 nums2 地址空间的值,而对 nums4 的赋值修改的是 nums3 和 nums4 共用的内存地址。

『2』那能说说容量 cap 和长度 len 的区别是什么吗? 容量 cap 是指为该切片准备了 cap 大小的内存空间,当切片中的数据数量不超过 cap 时,切片是不需要进行扩容的。而长度 len 表示的是切片中元素数量有 len 个,主要应用于切片的初始化。 例如下面这段代码,slice1 在赋值时是会报错的,只能通过 append 添加元素,而 slice2 是可以成功赋值的,通过 append 向 slice2 添加加元素时从 slice2[1]开始赋值。

『3』Array 和 Slice 的扩容? 扩容策略 超超:数组作为基本数据类型,数组的长度也是类型的一部分,所以数组每次扩容都需要新建一个数组做值拷贝

切片作为包装类型,如果在切片中添加元素后,切片长度未超过 cap,只需在 slice.array 指向的内存区域进行赋值。如果切片长度超过了容量 cap,扩容需要修改 slice.array 指向的内存大小,再进行值拷贝,扩容函数同样定义在 runtime.slice.go 中。 往数组添加元素都需要新的内存,把旧数据复制过去,往切片添加元素只要不超过 cap,就只是在 slice.array 所指的区域添加元素,如果超过需要修改 slice.array 的指向。 数组的等号是浅拷贝,切片的拷贝是深拷贝 切片是两个阀值判断

var newcap int
if needcap > 2*old.cap{
	newcap=needcap
}else{
	if old.cap<1024{
		newcap=2*old.cap
	}else{
		newcap=old.cap

	}
}

『4』在实际开发中,注意过切片的使用技巧吗? 切片和数组的选择:正如前面说的数组是基本数据类型,而切片是包装类型通过 slice.array 指针进行寻址,多了一个二次寻址的过程。因此在明确数列的长度不会变化时,我会优先选择数组而不是切片。用 Benchmark 测一下。

BenchmarkArrayGet-8     1000000000               0.282 ns/op           0 B/op
BenchmarkSliceGet-8     1000000000               0.420 ns/op           0 B/op

切片容量:切片在扩容时如果需要重新申请内存空间做值拷贝,将会非常耗时,这也是容量 cap 存在的意义。所以在声明切片时,尽可能的预见切片所需的大小并赋给 cap,避免切片的扩容。

func NewSlice() []int {
	slice := make([]int, 0, 10)
	for i := 0; i < 10; i++ {
		slice = append(slice, i)
	}
	return slice
}

从大切片拷贝一小部分数据到小切片的时候,不要用等号,要用 copy 因为内存块还存在外部引用时,该内存将会无法释放。小切片和大切片如果指向相同的内存,小切片一直被使用,但是大切片不怎么使用,但是大切片因为小切片的应用而得不到释放。

make 和 new 的区别?

slice := make([]int,10)
ch    :=make(chan interface,7)
tempmap := make(map[int]string)

内存逃逸

内存逃逸是指原本应该被存储在栈上的变量,因为一些原因被存储到了堆上。

栈区:主要存储函数的入参、局部变量、出参当中的资源由编译器控制申请和释放。 C++中堆区的空间是需要程序员自己通过关键字 new 和 delete 手动释放的 c 语言需要 malloc 函数去堆上申请内存,然后使用完了,用 free 释放 内存空间有两大部分,堆和栈,但是有些函数我们并不想他们在函数运行结束后销毁,那么我们就需要吧变量放在堆上分配,这种从内存中间栈上逃逸到内存中间的堆上的现象就是内存逃逸。

Go 语言中由编译器决定对象真正存储位置,如果使用关键字 new 申请的对象还会被存储到栈上吗? 答:Go 由编译器决定对象真正存储的位置。即使是用 new 申请的内存,如果编译器发现 new 出来的内存在函数结束后就没有使用了且申请内存空间不是很大,那么 new 申请的内存空间还是会被分配在栈上,毕竟栈访问速度更快且易于管理。 如果编译器发现 new 申请的内存在函数结束后没有被使用,那么就会分配在栈上

使用逃逸分析命令go build -gcflags="-m" main.go

内存逃逸常见情景

第一种情况变量在函数外部没有引用,优先放到栈中。最典型的例子就是刚刚说的new的内存分配问题,当new的内存空间没有被外部引用,且申请的内存不是很大时就会被放在栈上而不是堆上.

go 语言的逃逸分析是——引入 GC 来管理堆上的对象,当堆上的某个对象不可以达的时候,就回收它。具体做法是:时标记清除算法的基础+三色标记法+写屏障技术

最新的 go 版本的原理:

  • 指向栈对象的指针不能存储在堆上

返回局部变量的指针:当一个函数返回局部变量的指针时,该变量会从栈逃逸到堆。

func foo() *int {
    x := 1
    return &x
}

将局部变量存储到全局变量中:局部变量赋值给全局变量或者数据结构,会导致内存逃逸。

var global *int

func bar() {
    x := 1
    global = &x
}

闭包引用:如果一个闭包引用了一个局部变量,该变量会逃逸到堆上。

func closure() func() int {
    x := 1
    return func() int {
        return x
    }
}

动态类型:使用interface{}或反射可能导致内存逃逸,因为编译器很难确定具体的类型和生命周期。

通过通道传递指针:将局部变量的指针通过channel传递给其他goroutine。

ch := make(chan *int)

func send() {
    x := 1
    ch <- &x
}

切片和map的操作:对切片进行append操作或者向map添加元素,有时也会导致内存逃逸。

Tags: golang
Share: X (Twitter) Facebook LinkedIn