当前位置:   article > 正文

Go语言面试宝典_go面试宝典

go面试宝典

Go语言

golang的接口原理,怎么用,接口的作用

多线程的一些理解,进程线程协程

数据库索引,哈希索引、B+树索引,哪些字段适合加索引

new和make的区别

golang哪些类型是值传递,哪些是引用传递,区别是什么,用的场景

golang中都是采用值传递,即拷贝传递,也就是深拷贝。没有引用传递。之所有有些看起来像是引用传递的场景,是因为Golang中存在着引用类型,如slice、map、channel、function、pointer这些天生就是指针的类型,在传递的时候是复制的地址。

堆和栈,golang变量的内存分配,什么时候分配在栈,什么时候堆

golang的channel读写流程

编译过程

https://zhuanlan.zhihu.com/p/454419598

https://blog.csdn.net/yanghaitao5000/article/details/118913541

init()

在main函数之前执行。init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。 init()

函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证

执行顺序:import –> const –> var –> init() –> main()

一个文件可以有多个 init() 函数

变量分配

  1. 如果一个函数结束之后外部没有引用,那么优先分配到栈中(如果申请的内存过大,栈区存不下,会分配到堆)。
  2. 如果一个函数结束之后外部还有引用,那么必定分配到堆中。
go run -gcflags "-m -l" .\main.go // 通过这个命令进行逃逸分析
  • 1

堆上动态内存分配的开销比栈要大很多,所以有时我们传递值比传递指针更有效率

为什么?

  • 分配在栈上:函数运行结束后自动回收
  • 分配在堆上:函数运行结束后由GC回收

因为复制是栈上完成的操作,开销要比变量逃逸到堆上再分配内存要少的多,比如说:

func func1(a int, b int) *int {
    var c = a + b
    func2(&c)
}
func func2(p *int){
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

如何决定一个变量是采用值传递还是引用传递进行分配?

变量不能被修改:值传递

变量是一个大的结构体:指针传递

结构体struct

结构体传递的时候使用的是值传递(值拷贝)。

值类型,如果比较大的话,通过引用传递,如果比较小通过值传递。

type stu struct {
	name string
	age  int
}
new(stu) 等同于 &stu{}, 都是指针
  • 1
  • 2
  • 3
  • 4
  • 5

值接收者和指针接收者

方法和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,它就变成了一个方法。接收者可以是值接收者也可以是指针接收者。值类型和指针类型都可以调用值接收者的方法和指针接收者的方法。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nYswO5Mx-1689738567233)(C:/Users/kang/Desktop/照片/不同类型调用者和接收者.png)]

实现了接收者是值类型的方法,会隐含实现接收者是指针类型的方法。

如果方法的接收者是值类型,无论调用者是对象或者还是指针,修改的都是对象对的副本,不影响调用者。

而如果方法的接收者是指针类型,则修改的是对象本身。

使用指针作为方法的接收者理由如下:

修改接收者指向的值;值的类型为大结构体时,避免直接复制,高效。

切片slice

切片扩容:扩大容量,内存对齐(查表向上取整内存对齐)。

newcap := old.cap // 现有容量
doublecap := newcap + newcap
if cap > doublecap { // cap期望容量
    newcap =cap
} else {
    const threshold =256
    if old.cap < threshold {
        newcap = doublecap
    } else {
        for 0 < newcap && newcap < cap {
			newcap += (newcap +3*threshold) /4         
		}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

接口interface

https://blog.csdn.net/wudebao5220150/article/details/128095696

为啥用接口:

  1. 实现泛型编程
  2. 隐藏具体的实现
  3. 实现面向对象编程中的多态用法
  4. 空接口可以接受任何类型的参数

常见应用:

  1. 接口赋值:可以将一个实现接口的对象实例赋值给接口

    1. 通过对象实例赋值
    2. 通过接口赋值
  2. 接口嵌套:口不能递归嵌套

  3. interface 与 nil 的比较

    因为一个 interface{} 类型的变量包含了2个指针,一个指针指向值的类型,另外一个指针指向实际的值。对一个 interface{} 类型的 nil 变量来说,它的两个指针都是0;但是 var a State 传进去后,指向的类型的指针不为0了,因为有类型了, 所以比较为 false。 interface 类型比较, 要是两个指针都相等,才能相等。

  4. 类型断言

    非空接口类型推断的实质是 iface 中 *itab 的对比。*itab 匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。

    空接口类型推断的实质是 eface_type 的对比。_type 匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。

    ret, ok := interface.(type)

    str, ok := value.(string)
    if ok {
        fmt.Printf("string value is: %q\n", str)
    } else {
        fmt.Printf("value is not a string\n")
    }
    如果类型断言失败,则str将依然存在,并且类型为字符串,不过其为零值,即一个空字符串。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
  5. 类型查询

    类型查询的对象必须是接口类型,因为一个具体的类型是固定的,声明以后就不会变化,所以具体类型的变量都不存在类型查询的运算。

    Type Switches case 如果跟的是非空接口的类型名,则会调用 runtime.assertI2I2() 判断 case 是否匹配,如果匹配成功,进入 case 内部类型断言会再调用runtime.assertI2I() 拿到 iface。

    Type Switches case 如果跟的是空接口的类型名,则先根据 hash 值匹配类型,hash 匹配成功再匹配 *itab,两个都匹配成功才能进入 case 内部。进入以后的类型断言还会再判断一次 *itab 是否一致。

    switch x.(type)

    var value interface{} // Value provided by caller.
    switch str := value.(type) {
    case string:
        return str //type of str is string
    case int: 
        return int //type of str is int
    }
    语句switch中的value必须是接口类型,变量str的类型为转换后的类型。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
  6. 动态派发/多态

    多态是一种运行期的行为,它有以下几个特点:

    • 一种类型具有多种类型的能力

    • 允许不同的对象对同一消息做出灵活的反应

    • 以一种通用的方式对待个使用的对象

    • 非动态语言必须通过继承和接口的方式来实现

    指针实现的动态派发造成的性能损失非常小,相对于一些复杂逻辑的处理函数,这点性能损失几乎可以忽略不计。

    结构体实现的动态派发性能损耗比较大。结构体在方法调用的时候需要传值,拷贝参数,这里导致性能损失比较大。

    在开发中,所有动态派发的代码用指针来实现。

底层数据结构的实现:

如果一个具体类型实现了一个接口要求的所有方法,那么这个类型实现了这个接口。当具体类型实现了一个接口时,这个具体类型才可以赋值给该接口。另外,因为空接口类型是没有定义任何方法的接口,因此所有类型都实现了空接口,也就是说可以把任何类型赋给空接口类型

GO在存储接口类型的变量时, 根据接口中是否包含方法, 分别存储为不同类型的结构体.

iface区别于eface的地方, 就是iface需要额外存储接口的方法信息.

//runtime/runtime2
type iface struct {     //非空接口(不包含方法的接口类型)
	tab  *itab          //存放的是类型、方法等信息
	data unsafe.Pointer //指向的 iface 绑定对象的原始数据的副本
}
// 空 interface 数据结构 是没有方法集的接口。所以不需要 itab 数据结构
// 只有当 2 个字段都为 nil,空接口才为 nil。空接口的主要目的有 2 个,一是实现“泛型”,二是使用反射。
type eface struct {
	_type *_type
	data  unsafe.Pointer
}
type itab struct {
	inter *interfacetype //存的是interface 自己的静态类型
	_type *_type         //interface 对应具体对象的类型
	hash  uint32         // copy of _type.hash. Used for 类型断言.
	_     [4]byte
	fun   [1]uintptr // 函数指针,它指向的是具体类型的函数方法
}
type interfacetype struct {
	typ     _type     // 类型元信息
	pkgpath name      // 包路径和描述信息等等
	mhdr    []imethod // 存的是各个 interface 函数方法在段内的偏移值 offset,知道偏移值以后才方便调用
}

// _type 是所有类型原始信息的元信息
type _type struct {
	size       uintptr                                   // 类型占用内存大小
	ptrdata    uintptr                                   // 包含所有指针的内存前缀大小
	hash       uint32                                    // 类型 hash
	tflag      tflag                                     // 标记位,主要用于反射
	align      uint8                                     // 对齐字节信息
	fieldAlign uint8                                     // 当前结构字段的对齐字节数
	kind       uint8                                     // 基础类型枚举值
	equal      func(unsafe.Pointer, unsafe.Pointer) bool // 比较两个形参 对应对象的类型是否相等
	gcdata     *byte                                     // GC 类型的数据
	str        nameOff                                   // 类型名称字符串在二进制文件段中的偏移量
	ptrToThis  typeOff                                   // 类型元信息指针在二进制文件段中的偏移量
}

  • 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

通道channel

https://blog.csdn.net/weixin_42309691/article/details/125694412

端到端的数据传输,这有点像我们平常使用的消息队列,只不过 channel 的发送方和接受方是 goroutine 对象,属于内存级别的通信。

type hchan struct {
 qcount   uint   // channel 里的元素计数
 dataqsiz uint   // 可以缓冲的数量,如 ch := make(chan int, 10)。 此处的 10 即 dataqsiz
 elemsize uint16 // 要发送或接收的数据类型大小
 buf      unsafe.Pointer // 当 channel 设置了缓冲数量时,该 buf 指向一个存储缓冲数据的区域,该区域是一个循环队列的数据结构
 closed   uint32 // 关闭状态
 sendx    uint  // 当 channel 设置了缓冲数量时,数据区域即循环队列此时已发送数据的索引位置
 recvx    uint  // 当 channel 设置了缓冲数量时,数据区域即循环队列此时已接收数据的索引位置
 recvq    waitq // 想读取数据但又被阻塞住的 goroutine 队列
 sendq    waitq // 想发送数据但又被阻塞住的 goroutine 队列
 
 lock mutex
 ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

读写顺序

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qWeTCs3s-1689738567235)(面试问题总结.assets/image-20230306163506209.png)]

**先写后读:**这一次会优先判断缓冲数据区域是否已满,如果未满,则将数据保存在缓冲数据区域,即环形队列里。如果已满,则和之前的流程是一样的。当 G2 要读取数据时,会优先从缓冲数据区域去读取,并且在读取完后,会检查 sendq 队列,如果 goroutine 有等待队列,则会将它上面的 data 补充到缓冲数据区域,并且也对其设置 goready 函数。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x9mHfLkt-1689738567235)(面试问题总结.assets/image-20230306164350380.png)]

死锁(阻塞)场景:

  • 当channel中没有数据的时候,直接读会死锁;

  • 当channel中数据满的时候,直接写会死锁;

解决方案是采用select语句,再default放默认处理方式。

  • 向一个关闭的channel中写数据,会panic。只能读不能写。
  • 关闭channel后读数据,如果缓冲区中有数据,则可以读到数据并且第二个bool为ture;如果没有数据,则读到零值,false。

关闭channel的过程 closechan

close 逻辑比较简单,对于一个 channel,recvq 和 sendq 中分别保存了阻塞的发送者和接收者。关闭 channel 后,对于等待接收者而言,会收到一个相应类型的零值。对于等待发送者,会直接 panic。所以,在不了解 channel 还有没有接收者的情况下,不能贸然关闭 channel。

close 函数先上一把大锁,接着把所有挂在这个 channel 上的 sender 和 receiver 全都连成一个 sudog 链表,再解锁。最后,再将所有的 sudog 全都唤醒。

sender 会继续执行 chansend 函数里 goparkunlock 函数之后的代码,很不幸,检测到 channel 已经关闭了,panic。receiver 则比较幸运,进行一些扫尾工作后,返回。这里,selected 返回 true,而返回值 received 则要根据 channel 是否关闭,返回不同的值。如果 channel 关闭,received 为 false,否则为 true。这我们分析的这种情况下,received 返回 false

channel存在数据丢失的问题吗

正常情况下不会丢失数据,因为如果channel关闭后,依然还是可以读数据的。除非就是goroutine阻塞的时候直接把它停掉了。

如何优雅的关闭 channel

根据sender和receiver的个数,可以分为以下几种情况:

1、一个sender,一个receiver

2、一个sender,M个receiver

3、N个sender,一个receiver

4、N个sender,M个receiver

对于1,2这两种情况,只有一个sender,直接从sender段关闭就好。

第3种情形下,优雅关闭channel的方法是:唯一的receiver发出一个关闭channel的信号,senders监听到关闭信号后,停止发送数据。

对于第4种情形找一个中间人的角色。

select原理

select 就是用来监听和 channel 有关的 IO 操作,既可以用于 channel 的数据接收,也可以用于 channel 的数据发送。

特点:

  • select语句中除default外,各case执行顺序是随机的
  • select语句中如果没有default语句,则会阻塞等待任一case
  • select语句中读操作要判断是否成功读取,关闭的channel也可以读取
  • 对于空的select语句,程序会被阻塞,准确的说是当前协程被阻塞,同时Golang自带死锁检测机制,当发现当前协程再也没有机会被唤醒时,则会panic。所以上述程序会panic。
func main() {
    select {
    }
}
  • 1
  • 2
  • 3
  • 4

原理:

type scase struct { // 每个case都对应一个这样的结构体 
    c           *hchan         // chan 当前case语句所操作的channel指针
    kind        uint16 // 表示该case的类型,分为读channel、写channel和default
    elem        unsafe.Pointer // data element 表示缓冲区地址
}
  • 1
  • 2
  • 3
  • 4
  • 5

select实现逻辑

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
    //1. 锁定scase语句中所有的channel
    //2. 按照随机顺序检测scase中的channel是否ready
    //   2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
    //   2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
    //   2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
    //3. 所有case都未ready,且没有default语句
    //   3.1 将当前协程加入到所有channel的等待队列
    //   3.2 当将协程转入阻塞,等待被唤醒
    //4. 唤醒后返回channel对应的case index
    //   4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
    //   4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

map

Go语言中map是引用类型,必须初始化才能使用。map可以看作是一个hash表,其中的key的类型是受限的,value是任意类型。采用哈希查找表的数据结构,链表法解决冲突。哈希表持有一定数量的桶,哈希桶存储键值对。

在哈希表中查找某个键值对时,通过hash函数计算key对应的hash值,哈希值的低B位定位在哪一个桶中,高8位定位桶中的槽位,然后对比哈希值是否相等。

多个 goroutine 操作同一个 map是不安全的。使用原生 map,配合 sync 包的 Mutex 和 RWMutex 构建并发安全的 map。也可以使用并发安全 map - sync.Map。

map实现的数据结构:哈希查找表(碰撞:链表法、开放地址法)、搜索树

哈希查找表搜索树
最快O(1)、最慢O(n)最差O(logN)
返回的key值序列乱序从小到大

Go语言:哈希查找表+链表法

数据结构

高8位决定在桶的哪一个槽位,低B位决定在哪一个桶。

image-20230314211909286

如何扩容

装载因子:元素个数/桶数量

扩容是渐进式的,并不会马上搬迁完所有的key

扩容时机:

  1. 装在因子超过阈值:6.5 (元素太多桶太少)
    1. 扩容策略:将B加1,桶的数量变为原来的2倍。
  2. overflow的桶数量过多:B < 15 & overflow > 2^B;B >= 15 & overflow > 2^15 (元素太少桶太多)
    1. 扩容策略:创建一个新桶,将老桶中的元素移到新桶中去。

为什么map是无序的?

  1. map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。
  2. 在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。

map的key可以有哪些类型

可比较类型都可以作为key,除了slice、map、func。

float64作为key的时候,会先转成unit64类型,在插入key中。但使用的时候存在精读丢失的问题

读写锁
type RWMutex struct {
    w           Mutex  //用于控制多个写锁,获得写锁首先要获取该锁,如果有
一个写锁在进行,那么再到来的写锁将会阻塞于此
    writerSem   uint32 //写阻塞等待的信号量,最后一个读者释放锁时会释放信
号量
    readerSem   uint32 //读阻塞的协程等待的信号量,持有写锁的协程释放锁后
会释放信号量
    readerCount int32  //记录读者个数
    readerWait  int32  //记录写阻塞时读者个数
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nHVZls9a-1689738567236)(Go+项目.assets/image-20230323221431985.png)]

写操作如何阻塞读?

readerCount是个整型值,表示读者数量,每次读锁定将该值+1,每次解除读锁定将该值-1,所以readerCount取值为[0, N],N为读者个数,实际上最大可支持**230**个并发读者。当写锁定时,会先将readerCount减去230,变为负数。读锁定到来时检测到readerCount为负值,便知道有写操作在进行,只好阻塞等待。

读操作如何阻止写?

读锁定会先将RWMutext.readerCount加1,此时写操作到来时发现读者数量不为0,会阻塞等待所有读操作结束。

写操作如何不被饿死?

写操作到来时,会把RWMutex.readerCount值拷贝到RWMutex.readerWait中,用于标记排在写操作前面的读者个数前面的读操作结束后,除了会递减RWMutex.readerCount,还会递减RWMutex.readerWait值,当RWMutex.readerWait值变为0时唤醒写操作

互斥锁
type Mutex struct {
    state int32 // 互斥锁的状态,比如是否被锁定 32位的整形变量,分成四份
    sema  uint32 // 信号量 协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。
}
  • 1
  • 2
  • 3
  • 4

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9w84K6gV-1689738567236)(Go+项目.assets/image-20230323223019294.png)]

加锁——Looked=1——或阻塞-Waiter++

解锁——Looked=0——唤醒其他协程-Waiter–

自旋过程

加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测Locked位是否变为0

优点:优势是更充分的利用CPU,尽量避免协程切换。

缺点:如果每次都通过自旋获得锁,就会有协程处于饥饿状态。为了避免协程长时间无法获取锁,自1.8版本以来增加了一个状态,即Mutex的Starving状态。这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。

**Mutex模式:**根据Starvin为来决定是什么模式:normal模式——starvation模式(不会启动自旋)

**Worken:**用于加锁和解锁过程的通信

重复Unlock会panic:会唤醒多个协程

使用技巧:加锁后立即使用defer解锁;加锁 解锁最好出现在同一个代码块;避免重复Unclock

面向对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H3WYs07k-1689738567237)(面试问题总结.assets/image-20230303142009489.png)]

内存分配

http://www.guoxiaolong.cn/blog/?id=11458

核心思想:

  • 每次从操作系统申请一大块儿的内存,由Go来对这块儿内存做分配,减少系统调用。
  • 核心思想就是把内存切分的非常的细小,分为多级管理,以降低锁的粒度。
  • 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J6MjLUwv-1689738567238)(面试问题总结.assets/722f6472-c971-4c9c-aa66-c9e79cf722e7.jpg)]

如何回答

整体过程

过程:通过页分配器以8KB大小的页为单位从操作系统申请内存,将页组织成对象,通过对象分配器将其分配给用户程序。

关键数据结构

mheap类型的全局变量管理着系统预分配的全部内存,Golang程序启动时申请一大块内存,可以分为spans、bitmap、arean三个区域。arean存放一个个的(8kb)page,spans存放指向arean中page的指针,bitmap用于表示每个arean中的页是否存有对象,用于后续的垃圾回收。

span是内存管理的基本单位,每个span中包含一个或多个连续页,为了满足小对象分配, span中的一页会划分更小的力度,而对于大对象比如超过页大小,则通过多页实现。

根据对象的大小,划分了一系列的class,每个class都代表一个固定大小的对象。每个span用于管理特定的class对象,根据对象大小,span将一个或多个页拆分成多个块进行管理。

mcentral(中枢跨度缓存)是管理span的数据结构,当线程需要内存时从mcentral管理的span中申请内存,为了避免多线程申请内存时不断地加锁,Golang为每个线程分配了span的缓存,mcache(本地跨度缓存)作为线程的私有资源。

内存分配过程

根据对象的大小不同将对象分配的流程分成三种情况:微小对象(< 16b)、小对象、大对象( > 32kb)。

大对象:直接通过hmeap进行分配,从堆上申请n个连续的页面。

小对象

  1. 获取当前线程的私有缓存mcache
  2. 根据size计算出适合的class的ID
  3. 从mcache的alloc[class]链表中查询可用的span
  4. 如果mcache没有可用的span则从mcentral申请一个新的span加入mcache中
  5. 如果mcentral中也没有可用的span则从mheap中申请一个新的span加入mcentral
  6. 从该span中获取到空闲对象地址并返回

微小对象:与小对象的分配过程类似,不同的地方在于会将多个微小对象进行合并,形成16B大小的块直接进行管理和释放。

组件:

  • mspan为内存管理的基础单元,直接存储数据的地方。
  • mcache:每个运行期的goroutine都会绑定的一个mcache(具体来讲是绑定的GMP并发模型中的P,所以可以无锁分配mspan,后续还会说到),mcache会分配goroutine运行中所需要的内存空间(即mspan)。
  • mcentral为所有mcache切分好后备的mspan
  • mheap代表Go程序持有的所有堆空间。还会管理闲置的span,需要时向操作系统申请新内存。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aYzMKQSc-1689738567238)(面试问题总结.assets/image-20230306161443004.png)]

span

一个span对应一个页,是内存管理的基本单位,每个span中包含一个或多个连续页,为了满足小对象分配, span中的一页会划分更小的块,而对于大对象比如超过页大小,则通过多页实现。

根据对象大小,划分了一系列class, 每个class都代表一个固定大小的对象。

mcentral

用来管理span的数据结构各线程需要内存时从mcentral管理的span中申请内存。每个mcentral保存一种特定类型的全局mspan列表,包括已分配出去的和未分配出去的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-duAzQmbM-1689738567238)(面试问题总结.assets/7c510ed3-f4da-4d99-9efc-0e181cda9b06.jpg)]

type mcentral struct {
    lock      mutex     //互斥锁
    spanclass spanClass // span class ID
    nonempty  mSpanList // non-empty 指还有空闲块的span列表    
    empty     mSpanList // 指没有空闲块的span列表
    nmalloc uint64      // 已累计分配的对象个数
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

线程从central获取span步骤如下:

  1. 加锁

  2. 从nonempty列表获取一个可用span,并将其从链表中删除

  3. 将取出的span放入empty链表

  4. 将span返回给线程

  5. 解锁

  6. 线程将该span缓存进cache

线程将span归还步骤如下:

  1. 加锁
  2. 将span从empty列表删除
  3. 将span加入noneempty列表
  4. 解锁

mcache

为了避免多线程申请内存时不断的加锁,goroutine为每个线程分配了span内存块的 缓存。cache作为线程的私有资源为单个线程服务,而central则是全局资源,为多个线程服务,当某个线程内存不足时会向central申请,当某个线程释放内存时又会回收进central。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Hczz8Zs8-1689738567239)(面试问题总结.assets/image-20230306125054832.png)]

type mcache struct {
    alloc [67*2]*mspan // 按class分组的mspan列表,每个元素代表了一种class类型
}
  • 1
  • 2
  • 3

根据对象是否包含指针,将对象分为noscan和scan两类,其中noscan代表没有指针,而scan则代表有指针,需要GC进行扫描。

heap

每个mcentral对象只管理特定的class规格的span。事实上每种class都会对应一个mcentral,这个mcentral的集合存放于mheap数据结构中。mheap管理着全部的内存,事实上Golang就是通过一个mheap类型的全局变量进行内存管理的。

内存分配过程:

  1. 获取当前线程的私有缓存mcache
  2. 根据size计算出适合的class的ID
  3. 从mcache的alloc[class]链表中查询可用的span
  4. 如果mcache没有可用的span则从mcentral申请一个新的span加入mcache中
  5. 如果mcentral中也没有可用的span则从mheap中申请一个新的span加入mcentral
  6. 从该span中获取到空闲对象地址并返回

内存泄漏

在写程序的过程中,由于程序的设计不合理导致对之前使用的内存失去控制,无法再利用这块内存区域;短期内的内存泄漏可能看不出什么影响,但是当时间长了之后,日积月累,浪费的内存越来越多,导致可用的内存空间减少,轻则影响程序性能,严重可导致正在运行的程序突然崩溃。

slice造成内存泄漏

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-80DpOFFE-1689738567240)(1.Go+项目.assets/image-20230605191705059.png)]

	slice1 := []int{3, 4, 5, 6, 7}
	slice2 := initSlice[1:3]
  • 1
  • 2
  • 只有一个slice1的时候,即没有任何其他切片对数组的引用,若此时slice1不再使用了,slice1和数组都可以被gc掉
  • 当还有其它slice对数组的引用的时候,slice1不在使用时,slice1可以被gc掉,但是数组和slice2无法被gc,这就会导致数组剩余部分的没有被使用也没有被回收

解决方法:

  1. 可以采用append的方法,append不会直接引用原来的数组,而是会新申请内存来存放数据。
initSlice := []int{3, 4, 5, 6, 7}
var partSlice []int
partSlice = append(partSlice, initSlice[1:3]...)	
  • 1
  • 2
  • 3
  1. 使用copy代替直接切片的写法
initSlice := []int{3, 4, 5, 6, 7}
//partSlice := initSlice[1:3]
partSlice := make([]int, 2)
copy(partSlice, initSlice[1:3])	
  • 1
  • 2
  • 3
  • 4
time.Ticker造成内存泄漏

go语言的time.Ticker主要用来实现定时任务time.NewTicker(duration) 可以初始化一个定时任务,里面填写的时间长度duration就是指每隔 duration 时间长度就会发送一次值,可以在 ticker.C 接收到,这里容易造成内存泄漏的地方主要在于编写代码过程中没有stop掉这个定时任务,导致定时任务一直在发送,从而导致内存泄漏

解决方法是ticker.Stop()

goroutine造成内存泄漏

没有对阻塞的场景进行处理,通过runtime.NumGoroutine()输出程序运行前后goroutine的数量来进行判断。

向满的channel发送:

​ 无缓存:

​ 有缓存:

从空的channel接受:

向nil的channel发送或接收:

解决办法:

发生泄漏前:发送者和接收者的数量最好要一致,channel记得初始化,不给程序发生内存泄漏的机会

发生泄漏后

采用go tool pprof分析内存的占用和变化,细节不在本篇文章讲解

垃圾回收

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2wOpYvFm-1689738567240)(面试问题总结.assets/image-20230304105819251.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BnJq2AMm-1689738567242)(面试问题总结.assets/image-20230304105916879.png)]

GMP模型

gmp模型中为什么要有p

如果没有P的话,M从一个全局队列中取G,锁竞争开销大。

有P之后的改变:

  • 每个P都有自己的本地队列,大幅减轻了对全局队列的依赖,减少了锁竞争。

  • 实现Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。

如果是想实现本地队列、Work Stealing 算法,那 为什么不直接在 M 上加呢,M 也照样可以实现类似的功能。为什么又再加多一个 P 组件?

  • 一般来讲,M 的数量都会多于 P(M的数量上限为1万,P的数量默认为CPU核心数。)。如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。那么队列的管理会变得复杂。

  • M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。

GMP调度模型是不是相当于有一个线程池

这么理解大致上没有错,但是它和线程池还是有点差异的。主要差在逻辑处理器P上。

调度器的设计策略

线程复用、利用并行、抢占式调度、全局队列。

大量创建go协程的问题

本地队列的限制是256个协程

  • 内存开销:go协程大约占2k的内存,线程2MB
  • 调度开销:runntime.Gosched()当前协程主动让出 CPU 去执行另外一个协程
  • gc开销:
  • cpu开销:最终协程是要内核线程来执行,我们知道在GMP模型中,G阻塞后,会新创建M来执行,一个M往往对应一个内核线程,当创建大量go协程的时候,内核线程的开销可能也会增大

P的数量为什么不能超过虚拟处理器的数量

每个P都会对应一个处理器进行工作,超过这个数量没有,依然只会有那么多个P在工作。

消息队列

是什么、应用场景、需要解决哪些问题、具体的实现方式。

为什么使用MQ:

  1. 流量削峰:解决高并发问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DmTsi5Sg-1689738567242)(面试问题总结.assets/image-20230307165516574.png)]

  1. 应用解耦:提升系统可用性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3yxNwYOm-1689738567242)(面试问题总结.assets/image-20230307165631689.png)]

  1. 异步处理:提升响应速度

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NiGOKO6r-1689738567242)(面试问题总结.assets/image-20230307165854170.png)]

常见问题总结

什么是消息队列:

使用队列来通信的组件,本质是一个转发器,包含发消息、存消息、消费消息的过程。常见有RabbitMQ、kafka、RocketMQ。

经典应用场景:

  1. 应用解耦:订单系统
  2. 流量削峰:秒杀系统
  3. 异步处理:用户注册场景
  4. 消息通讯:聊天室
  5. 远程调用

如何解决消息丢失的问题:

三个角度:生产者不丢消息、存储者不丢消息、消费者不丢消息

  1. RocketMQ提供三种发送消息的方式:同步发送、异步发送、单向发送

若要保证消息不丢失,可以:

  • 采用同步方式发送,send消息方法返回成功状态,就表示消息正常到达了存储端Broker。
  • 如果send消息异常或者返回非成功状态,可重试。
  • 使用消息事务
  1. 确保消息持久化到此磁盘:刷盘机制(同步、异步)
  • 同步刷盘:只有消息持久化到磁盘后,存储端Broker主从节点都写入,(集群部署,master节点和slave节点)才会返回一个成功的ACK响应,保证消息不丢失但影响性能。
  • 异步刷盘:只要消息写入Pagecache缓存,只要写入主节点,就返回一个ACK响应,提高了性能但机器断电消息就会丢失。
  1. 消费者执行完业务逻辑,再反馈给Broker说消费成功。

如何保证消息的顺序性:

思想:将消息发送给同一个消费者,并且发送M1后收到ACK在发送M2。

例子····

消息队列有可能发生重复消费,如何避免:

如何处理消息挤压问题:

生产者的速度大于消费者

消息队列技术选型,RabbitMQ、kafka、RocketMQ:

消息中间件如何做到高可用:

单机没有高可用,高可用都是针对于集群而言。

如何保证数据的一致性,事务消息如何实现:

Rabbitmq

下图是RabbitMQ的基本结构:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5W6xEiGa-1689738567243)(1.Go+项目.assets/20e90392b4f1404d982478fabf813c97.png)]

组成部分说明:
Broker:消息队列服务进程,此进程包括两个部分:Exchange和Queue
Exchange:消息队列交换机,按一定的规则将消息路由转发到某个队列,对消息进行过虑。
Queue:消息队列,存储消息的队列,消息到达队列并转发给指定的消费者
Producer:消息生产者,即生产方客户端,生产方客户端将消息发送
Consumer:消息消费者,即消费方客户端,接收MQ转发的消息。

生产者发送消息流程:
1、生产者和Broker建立TCP连接。
2、生产者和Broker建立通道。
3、生产者通过通道消息发送给Broker,由Exchange将消息进行转发。
4、Exchange将消息转发到指定的Queue(队列)

消费者接收消息流程:
1、消费者和Broker建立TCP连接
2、消费者和Broker建立通道
3、消费者监听指定的Queue(队列)
4、当有消息到达Queue时Broker默认将消息推送给消费者。
5、消费者接收到消息。
6、ack回复

四种交换机的类型:

  1. fanout(扇型交换机):它会把所有发送到该交换器的消息路由到所有与该交换器绑定的队列中
  2. direct(直连型交换机):把消息路由到那些 BindingKey RoutingKey完全匹配的队列中。
  3. headers(主题交换机):根据发送的消息内容中headers 属性进行匹配

跨进程的通信机制.

image-20230307213609925

多个消费者可以订阅同一个队列,这时队列中的消息会被平均分摊(Round-Robin ,即轮询) 给多个消费者进行处理,而不是每个消费者都收到所有的消息进行处理。RabbitMQ不支持队列层面的广播消费,但支持交换机层面的广播(fanout)

image-20230307213755807

死信队列

当消息在一个队列中变成死信( dead message )之后,它能被重新被发送到另一个交换器中,这个交换器就是 DLX ,绑定 DLX 的队列就称之为死信队列。

消息变成死信一般由于一下几种情况:消息被拒绝;消息过期;队列达到最大长度

延迟队列

消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。

DLX 和 TTL 模拟出延迟队列的功能。

image-20230307214420336

优先级队列

持久化

image-20230307214945357

消息发送方(生产者〉并不知道消息是否真正地到达了 RabbitMQ

生产者确认的方法:

  1. 事务机制实现:发送一条消息后发送方会阻塞
  2. 发送方确认机制实现:异步(优点)批量确认
  • 事务机制和 publisher confirm 机制两者是互斥的,不能共存。
  • 事务和publisher confirm 机制确保的是消息能够正确地发送至 RabbitMQ ,这里的“发送至 RabbitMQ”的含义是指消息被正确地发往至 RabbitMQ 的交换器,如果此交换器没有匹配的队列,那么消息也会丢失。

消息怎么路由?

消息发布到交换机器时,消息会拥有一个路由键(routing key),在消息创建时设定,然后队列也会通过一个BindingKey(绑定键)来和交换机做绑定,消息到达交换机后,RabbitMQ会将消息的路由键与队列的绑定键进行匹配(针对不同的交换器有不同的路由规则)

RabbitMQ的消息是怎么发送的?

首先客户端会和RabbitMQ服务端创建一个TCP连接,而一旦TCP打开并通过了认证,客户端和RabbitMQ服务端之间就会创建一个Channel(信道),而信道是创立在“真实” TCP上的虚拟连接,AMQP命令都是通过信道发送出去的,每个信道都会有一个唯一的 id,不管是发布消息,订阅队列都是通过这个信道完成的。

完全可以直接使用 Connection 就能完成信道的工作,为什么还要引入信道呢?

选择 TCP 连接复用,不仅可以减少性能开销,同时也便于管理。每个线程把持一个信道,所以信道复用了 Connection TCP 连接。同时 RabbitMQ 可以确保每个线程的私密性,就像拥有独立的连接一样。当每个信道的流量不是很大时,复用单Connection 可以在产生性能瓶颈的情况下有效地节 TC 连接资源。但是当信道本身的流量很大时,这时候多个信道复用一个 Connection 就会产生性能瓶颈,进而使整体的流量被限制了。此时就需要开辟多个 Connection ,将这些信道均摊到这些 Connection 中。

如何确保消息正确地发送至RabbitMQ?

将信道设置成confirm模式(发送方确认模式),则所有在信道上发布的消息都会被指派一个唯一的 ID。然后只要消息被投递到了目标队列,或者持久化到了磁盘里,信道会发送一个确认给生产者(里面包含了消息的唯一ID),而如果因为RabbitMQ发生内部错误导致了消息丢失,则会发送一条nack(not acknowledged,未确认)消息,不过这种发送方确认模式是异步的,也就是说生产者可以继续发送别的消息,等确认消息传回来的时候,生存者可以定义自己的回调方法来对消息发送结果做相应的逻辑处理,比如失败了就重试再发送。也可以开启事务,然后发送消息,如果发送过程中岀现什么异常,事务就会回滚 〔channel. txRolIback ()),如果发送成功则提交事务 (channel. txCommit ())。

RabbitMQ怎样避免消息丢失?

ACK确认机制,消息持久化,采用镜像集群模式,消息补偿机制

如何避免消息重复投递或重复消费?

在消息生产时,MQ内部针对每条生产者发送的消息生成一个inner-msg-id,作为 去重的依据(消息投递失败并重侍),避免重复的消息进入队列;在消息消费时, 要求消息体中必须要有一个bizld (对于同一业务全局唯一,如支付ID、订单ID、 帖子ID等)作为去重的依据,避免同一条消息被重复消费。

比如:在写入消息队列的数据做唯一标示,消费消息时,根据唯一标识判断是否消费过; 假设你有个系统,消费一条消息就往数据库里插入一条数据,要是你一个消息重复两次,你 不就插入了两条,这数据不就错了?但是你要是消费到第二次的时候,自己判断一下是否已 经消费过了,若是就直接扔了,这样不就保留了一条数据,从而保证了数据的正确性。

如何保证RabbitMQ消息的顺序性?

拆分多个queue,每个queue —个consumer,就是多一些queue而已, 确实是麻烦点;或者就一个queue但是对应一个consumer,然后这个 consumer内部用内存队列做排队,然后分发给底层不同的worker来处理

如何保证RabbitMQ消息的可靠传输?

生产者消息不丢失:信道设置为confirm模式或者开启事务。

消息队列数据不丢失:开启持久化磁盘的配置,结合confirm一起使用,在消息持久化磁盘后再给生产者发送一个ACK信号。如何持久化呢:

  1. 将queue的持久化标识durable设置为true,则代表是一个持久的队列

  2. 发送消息的时候将deliveryMode=2

消费者不丢失消息:釆用了自动确认消息模式,改为手动确认消息即可!

RabbitMQ的工作模式

  • simple模式(即最简单的收发模式)

  • work工作模式(资源的竞争)共同争抢当前的消息队列内容,谁先拿到谁负责消费消息

  • publish/subscribe发布订阅(共享资源)

    ​ 每个消费者监听自己的队列; 生产者将消息发给broker,由交换机将消息转发到绑定此交换机的每个队列,每个绑定交换机的队列都将接收到消息。

  • routing路由模式

  • topic 主题模式(路由模式的一种)

如何保证高可用的?RabbitMQ 的集群

  • 单机模式
  • 普通集群模式:在多台机器上启动多个 RabbitMQ 实例,创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据 (元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。
  • 镜像集群模式:**真正高可用模式。**创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是 说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意 思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。

消息积压怎么办

临时扩容。先修复 consumer 的问题,确保其恢复消费速度,然后将现有 cnosumer 都停掉。 新建一个 topic,partition 是原来的 10 倍,临时建立好原先 10 倍的 queue 数量。 然后写一个临时的分发数据的 consumer 程序,这个程序部署上去消费积压的数据,消费之后不 做耗时的处理,直接均匀轮询写入临时建立好的 10 倍数量的 queue。 接着临时征用 10 倍的机器来部署 consumer,每一批 consumer 消费一个临时 queue 的数据。 这种做法相当于是临时将 queue 资源和 consumer 资源扩大 10 倍,以正常的 10 倍速度来消费 数据。等快速消费完积压数据之后,得恢复原先部署的架构,重新用原先的 consumer 机器来消费消息。

数据丢失怎么办

批量重导。将丢失的那批数据,写个临时程序,一点一点的查出来, 然后重新灌入 mq 里面去。

gin框架

中间件是为了过滤路由而发明的一种机制,也就是http请求来到时先经过中间件,再到具体的处理函数。

请求参数获取:

Get请求参数:
id := ctx.Query("id")
id, ok := ctx.GetQuery("id")
数组参数:
address := ctx.QueryArray("address")
address, ok := ctx.GetQueryArray("address")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
Post请求参数:
id := ctx.PostForm("id")
name := ctx.PostForm("name")
address := ctx.PostFormArray("address")
addressMap := ctx.PostFormMap("addressMap")
  • 1
  • 2
  • 3
  • 4
  • 5
路径参数:
请求url: http://localhost:8080/user/save/111
ctx.JSON(200, ctx.Param("id"))
  • 1
  • 2
  • 3

优雅启停

优雅关机就是服务端关机命令发出后不是立即关机,而是等待当前还在处理的请求全部处理完毕后再退出程序,是一种对客户端友好的关机方式。而执行Ctrl+C关闭服务端时,会强制结束进程导致正在访问的请求出现问题。

http.Server 内置的 Shutdown() 方法就支持优雅地关机。

参数校验

在 struct 结构体添加 binding tag,然后调用 ShouldBing 方法,下面是一个示例

type SignUpParam struct {
    Age        uint8  `json:"age" binding:"gte=1,lte=130"`
    Name       string `json:"name" binding:"required"`
    Email      string `json:"email" binding:"required,email"`
    Password   string `json:"password" binding:"required"`
    RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}
func main() {
    r := gin.Default()
	r.POST("/signup", func(c *gin.Context) {
    var u SignUpParam
    if err := c.ShouldBind(&u); err != nil {
        c.JSON(http.StatusOK, gin.H{
            "msg": err.Error(),
        })
        return
    }
    // 保存入库等业务逻辑代码...
    c.JSON(http.StatusOK, "success")
})
	_ = r.Run(":8999")
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

gin的route实现原理

  1. gin 的每种方法(POST, GET …)都有自己的一颗树,当然这个是根据你注册路由来的,并不是一上来把每种方式都注册一遍
  2. 当 gin 收到客户端的请求时, 第一件事就是去路由树里面去匹配对应的 URL,找到相关的路由, 拿到相关的处理函数(找到对应的 handler)

Context

B站:小徐先生1212-解说Golang context实现原理 公众号

主要在异步场景中用于实现并发协调控制以及对 goroutine 的生命周期控制. 除此之外,context 还兼有一定的数据存储能力

type Context interface { 
    Deadline() (deadline time.Time, ok bool) //返回 context 的过期时间;
    Done() <-chan struct{} // 返回 context 中的 channel 信号发射的作用
    Err() error 
    Value(key any) any //返回 context 中的对应 key 的值
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

实现类

emptyCtx

cancelCtx

type cancelCtx struct {
    Context // 父节点
   
    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}

type canceler interface {
    cancel(removeFromParent bool, err error)
    Done() <-chan struct{}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • embed 了一个 context 作为其父 context. 可见,cancelCtx 必然为某个 context 的子 context;
  • 内置了一把锁,用以协调并发场景下的资源获取;
  • done:实际类型为 chan struct{},即用以反映 cancelCtx 生命周期的通道;
  • children:一个 set,指向 cancelCtx 的所有子 context;
  • err:记录了当前 cancelCtx 的错误. 必然为某个 context 的子 context;

实现的方法:

valueCtx 用法小结

阅读源码可以看出,valueCtx 不适合视为存储介质,存放大量的 kv 数据,原因有三:

  • 一个 valueCtx 实例只能存一个 kv 对,因此 n 个 kv 对会嵌套 n 个 valueCtx,造成空间浪费;
  • 基于 k 寻找 v 的过程是线性的,时间复杂度 O(N);
  • 不支持基于 k 的去重,相同 k 可能重复存在,并基于起点的不同,返回不同的 v. 由此得知,valueContext 的定位类似于请求头,只适合存放少量作用域较大的全局 meta 数据.

Go并发控制的方法

  1. channel:一般用于协程之间的通信,可以用于数据传递和同步。一个G等待,一个G发生。
  2. atomic包:原子操作,用于对共享资源进行原子操作,避免数据竞争问题。计数器,通常用于对变量的操作
  3. sync.Mutex:互斥锁,用于对共享资源进行加锁和解锁,避免数据竞争问题
  4. sync.Once:一次性操作,配合单例模式,用于保证某个操作只会被执行一次,常用于初始化操作
  5. sync.Cond:条件变量,用于在协程之间进行特定条件的通信和同步。多个G等待,一个G发生。
  6. sync.WaitGroup:等待组,用于协调多个协程之间的同步,可以等待所有协程执行完毕再继续往下执行。一个G等待,多个G发生。
  7. contex:

sync.Cond原理

sync.Cond 内部维护了一个等待队列,队列中存放的是所有在等待这个 sync.Cond 的 Go 程,即保存了一个通知列表。sync.Cond 可以用来唤醒一个或所有因等待条件变量而阻塞的 Go 程,以此来实现多个 Go 程间的同步。

使用实例:

var done = false // 多个 Goroutine 阻塞等待的条件。

func read(name string, c *sync.Cond) {
	c.L.Lock()
	for !done {
		c.Wait() // 阻塞等待通知,需要上锁,因为 Wait() 会将主调加入条件变量的通知列表,需要修改条件变量
	}
	fmt.Println(name, "starts reading")
	c.L.Unlock()
}

func write(name string, c *sync.Cond) {
	fmt.Println(name, "starts writing")
	time.Sleep(time.Second)
	done = true
	fmt.Println(name, "wakes all")
	c.Broadcast() // 唤醒所有的等待者;c.Signal() 唤醒一个等待者
}

func main() {
	cond := sync.NewCond(&sync.Mutex{}) // 创建的时候需要关联一个锁

	go read("reader1", cond)
	go read("reader2", cond)
	go read("reader3", cond)
	write("writer", cond)

	time.Sleep(time.Second * 3)
}
  • 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

注意事项:

  • sync.Cond 不能被复制

sync.Cond 不能被复制的原因,并不是因为其内部嵌套了 Locker。NewCond 时传入的 Mutex/RWMutex 指针,对于 Mutex 指针复制是没有问题的。

主要原因是 sync.Cond 内部是维护着一个 Goroutine 通知队列 notifyList。如果这个队列被复制的话,那么在并发场景下导致不同 Goroutine 之间操作的 notifyList.wait、notifyList.notify 并不是同一个,这会导致出现有些 Goroutine 会一直阻塞。

  • 唤醒顺序

从等待队列中按照顺序唤醒,先进入等待队列,先被唤醒。

  • 调用 Wait() 前要加锁

调用 Wait() 函数前,需要先获得条件变量的成员锁,原因是需要互斥地变更条件变量的等待队列。在 Wait() 返回前,会重新上锁。重新上锁的原因是主调在 Wait 后会进行解锁操作,避免重复解锁引发 panic。

  • sync.Cond 和 channel 的区别?

实际上,我们可以使用无缓冲 channel 充当条件变量实现 Go 程同步。通过 close(ch) 表示广播通知,其他的 Goroutine 使用 for select 结构来接收通知就行了。(无缓冲channel + 关闭channel实现)

方法名功能
(wg * WaitGroup) Add(delta int)等待组的计数器 +1
(wg * WaitGroup) Done()等待组的计数器 -1
(wg * WaitGroup) Wait()当等待组计数器不等于 0 时阻塞直到变 0。

青训营项目思考

场景分析:

视频Feed流

  • 每个用户都能经常触达,具有高并发大流量的特性,接口需要做好限流、熔断、降级等高可用操作。

  • 引入层次缓存来解决DB访问的压力,防止瞬间流量涌入导致DB宕机

  • 可能需要Nginx实现资源动静分离、CDN加速、MongoDB、Redis等NoSQL

  • 进阶:为每个视频设置tag,根据用户行为数据,训练离线model用于个性化推荐(FM、Wide&Deep等)

个人主页

  • 个人主页也是频繁被访问的,尤其是大V明星用户,初步考虑采用缓存来缓存个人信息和投稿列表

视频投稿

  • 大V用户可能连续投稿好几部视频作品,并且视频投稿属于实时性要求不高的场景,可以考虑采用异步处理的方案(请求合并+定时任务)

视频点赞

  • 视频点赞是一个比较简单的操作(用户可能会频繁点击/取消),流量直接打在DB可能会承受不住,需要考虑使用缓存
  • 关于数据一致性,对于用户,点赞记录需要保证最终一致性;对于视频,允许不一致(favorite_count) (比如抖音热门视频的点赞数显示14.5w)
  • 采用 MQ + 定时任务异步处理点赞/取消点赞操作

如何保证一个用户只能点赞一次

使用redis 里面的Set数据类型

社交关系

  • 该场景适合采用Redis的ZSET结构来存储,实现按关注时间排序的关注/粉丝列表,同时集合的一些操作也能提供丰富的功能,例如互相关注,共同关注
  • 同时,需要考虑Redis的持久化、一致性、Bigkey问题

私信聊天

  • 需要计算好友列表(互相关注)
  • 每个好友聊天框显示最新一条消息(客户端轮询)
  • 打开聊天框显示全部聊天记录(消息去重)
  • 消息可能会涉及敏感话题,需要过滤

实现过程:封装websocket连接,服务器推送的方式。

技术选型

存储

  • 基础数据模型需要事务支持,采用MySQL持久化(用户表、视频元信息表、关注表等)
  • 热点数据缓存采用Redis(视频点赞、用户关注等),提供丰富的数据结构和持久化策略等特性
  • 视频、封面文件云端采用xx对象存储

数据库设计

  • url需要使用雪花ID生成短链接(oss链接可能过长)
  • comment、message的content字段长度可能过短

思考

redis和mysql数据库一致性问题?

热点场景解决方法

用户激增,出现热门视频的解决方法。

每次请求都会缓存多个视频。

视频接口限流、熔断、降级等高可用操作。

引入缓存来减轻访问DB的压力。

视频投稿采用异步处理的方式。

采用 MQ + 定时任务异步处理点赞/取消点赞操作。

redis的大key问题?

什么是大key:

String类型:value字节数>10KB
Hash/Set等类型:元素>5000或value总字节数>10MB

所谓的大key问题是某个key的value比较大,所以本质上是大value问题。key往往是程序可以自行设置的,value往往不受程序控制,因此可能导致value很大。

危害:

redis的一个典型特征就是:核心工作线程是单线程。导致慢查询、请求Redis超时。

解决方法:

  • 如果发现某些大key并非热key就可以在DB中查询使用,则可以在Redis中删掉
  • 压缩和拆分key

redis缓存是如何实现的?

数据库索引怎么建立的?

参数校验怎么做的?

项目

高并发场景解决方案

https://blog.csdn.net/weixin_42462881/article/details/106103482?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-106103482-blog-124526798.pc_relevant_multi_platform_whitelistv3&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-106103482-blog-124526798.pc_relevant_multi_platform_whitelistv3&utm_relevant_index=1

https://blog.csdn.net/happydecai/article/details/80366986?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-80366986-blog-106103482.pc_relevant_multi_platform_whitelistv3&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-80366986-blog-106103482.pc_relevant_multi_platform_whitelistv3&utm_relevant_index=1

高并发解决方案案例:

流量优化:防盗链处理

前端优化:减少HTTP请求,合并css或js,添加异步请求,启用浏览器缓存和文件压缩,CDN加速,建立独立图片服务器,

服务端优化:页面静态化,并发处理,队列处理

  • 数据库优化:数据库缓存,分库分表,分区操作,读写分离,负载均衡、主从数据库

    数据表数据类型优化、索引的优化、sql语句的优化、存储引擎使用InnoDB······

web服务器优化:负载均衡,nginx反向代理,7,4层LVS软件

浏览器缓存和数据压缩优化

建立独立的图片服务器

集群

  • 高可用集群:当集群中有某个节点失效的情况下,其上的任务会自动转移到其他正常的节点上。
  • 负载均衡集群:负载均衡集群运行时一般通过一个或者多个前端负载均衡器将工作负载分发到后端的一组服务器上,从而达到将工作负载分发。
  • 高性能计算集群:采用将计算任务分配到集群的不同计算节点而提高计算能力

消息队列

如何设计一个秒杀系统

场景特点:

  • 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增
  • 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功
  • 秒杀业务流程比较简单,一般就是下订单减库存。

设计理念:

限流: 鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端。

削峰:瞬间的高流量变成一段时间平稳的流量。利用缓存和消息中间件等技术。

异步处理:提高系统并发量,其实异步处理就是削峰的一种实现方式。

内存缓存:redis数据库

可拓展:将系统设计成弹性可拓展的,如果流量来了,拓展机器就好了。

架构方案:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bed1P5OI-1689738567243)(Go+项目.assets/20160921141623725)]将请求拦截在系统上游,降低下游压力

  • 浏览器端:

页面静态化:将活动页面上的所有可以静态的元素全部静态化,并尽量减少动态元素。通过CDN来抗峰值。
禁止重复提交:用户提交之后按钮置灰,禁止重复提交
用户限流:在某一时间段内只允许用户提交一次请求,比如可以采取IP限流

  • 网关层:

限制uid(UserID)访问频率:我们上面拦截了浏览器访问的请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。

  • 服务层:
  1. 采用消息队列缓存请求:既然服务层知道库存只有100台手机,那完全没有必要把100W个请求都传递到数据库啊,那么可以先把这些请求都写到消息队列缓存一下,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。
  2. 利用缓存应对读请求:对类似于12306等购票业务,是典型的读多写少业务,大部分请求是查询请求,所以可以利用缓存分担数据库压力。
  3. 利用缓存应对写请求:缓存也是可以应对写请求的,比如我们就可以把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
  • 数据库层:

统计网页浏览量

如果要满足高并发,那首先考虑用异步和缓存。所以考虑使用多线程加Redis的解决方案。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IyqoKVaU-1689738567244)(Go+项目.assets/image-20230323154052776.png)]

redis数据库:

key为:isViewd:articleId:ip(string类型):这个ip是否浏览过这个articleId;设置过期时间

key为:viewCount:articleId(string类型)value为:缓存的浏览量:无过期时间

定时任务:将缓存中的浏览量更新到数据库中。

查看浏览量最多的文章:数据全部存到redis中,zset

image-20230323155520548

CDN内容分发网络

https://www.zhihu.com/question/36514327?rf=37353035

CDN服务器优点类似于缓存,缓存了源站点的部分数据。

img

常见加密算法

目前常见的加密算法分类如下:

1,单向散列加密算法

常见算法包括:MD5、sha1、sha256等

2,对称加密算法

常见的算法有:DES、3DES、AES

3,非对称加密算法

常见算法包括:RSA、ECC

如何理解云原生

云原生是指以云计算为基础,利用容器化、微服务化等现代化技术,以应用程序的形式构建、部署、运行以及管理软件服务的方法。

在云原生架构中,应用程序被分解成更小、更敏捷的服务单元,这些单元可以分别开发、组装和扩展。每个服务单元都可以运行在多个容器实例中,并通过动态规划来自动管理实例数量和位置,实现高可用和自动扩展。

为了支持云原生架构,需要使用到自动化工具来管理服务、配置、监视和故障排查等任务,例如容器编排工具 Kubernetes、服务网格 Istio等。

通过采用云原生的架构模式,可以大幅提高软件开发、部署和运维的效率,降低成本,并且增加系统的可伸缩性和弹性。这种方式也是云计算时代的主流架构模式。

项目中RPC实现

数据结构

两个队列实现一个栈

思路:

img

两个栈实现一个队列

一个栈用来存数据,另一个栈用来取数据。当读取数据时,如果读取的栈没有数据,就将存储数据的栈数据导入到读取数据的栈,这样读取的时间复杂度是O(n),写入的时间复杂度是O(1),空间复杂度是O(n)

根据(1,5)随机数生成器,生成(1,7)之内的随机数

方法一:生成两个(1,5)的随机数,这样一共是25种情况,注意这两个数是有顺序的,从这25种情况中,取前21种,每三种代表(1,7)中的一个数字,如果取到的是这21种以外的情况,丢掉重新取。

方法二:生成三个(1,5)的随机数,分别表示一个二进制位,其中1和2映射为0,3跳过,4和5映射为1。这样产生的三位二进制数,即1-8这8个数字都是等概率的。如果产生的是8,那么丢弃即可。

解决哈希冲突的方法

解决哈希表冲突常用的方法包括开放地址法和链地址法。

  1. 开放地址法(Open Addressing)

开放地址法是指当发生哈希冲突时,继续探测哈希表中的空位置,直到找到空位置为止。开放地址法的实现方法包括线性探测、二次探测、双重哈希等。开放地址法的优点是实现简单,不需要额外的空间,但是容易产生聚集现象,即某些哈希值的冲突会集中在哈希表的某些区域,从而导致性能下降。

  1. 链地址法(Chaining)

链地址法是指将哈希表中哈希值相同的元素保存在同一个链表中,冲突的元素通过链表进行保存。链表可以用数组、树等数据结构实现。链地址法的优点是只需要额外的空间用来保存指针,解决聚集问题,但是由于需要通过指针进行查找,相对于开放地址法在性能上稍逊一些。

综上所述,对于哈希表冲突的解决方法,开放地址法适合解决小规模的哈希表,在实现简单的同时容易产生聚集现象;链地址法适用于规模较大的哈希表,解决聚集问题,但是需要用额外的空间来保存指针。

场景设计

设计停车场

某停车场(Parklot)有停车位(ParkingSpace)若干: //有一个入口和一个出口,入口完成扫描计时,出口完成结账及车位释放。 //停车位包含两类:货车位和小车位, //货车按每小时10元计价,每天最高累计120元, //小车位按每小时5元计价,每天最高累计60元。 //请注意提示剩余车位信息为该停车场设计一个管理系统,还原该场景,功能包括: //(1)车辆进入处理(2)车辆离开处理 //(3)计算当日停车场缴费总金额 等。

type ParkConfig struct {
	maxDailyPriceForTruck  int
	maxDailyPriceForCar    int
	pricePerHourTruck      int
	pricePerHourCar        int
	carParkingSpaceCount   int
	truckParkingSpaceCount int
}

type Parklot struct {
	feeCollected         int //总停车收费
	remainCarParkSpace   int
	remainTruckParkSpace int
	entryTimeStamps      map[string]time.Time
	parkConfig           ParkConfig
}

func NewParklot(parklot ParkConfig) *Parklot {
	return &Parklot{
		feeCollected:         0,
		remainCarParkSpace:   parklot.carParkingSpaceCount,
		remainTruckParkSpace: parklot.truckParkingSpaceCount,
		entryTimeStamps:      make(map[string]time.Time),
		parkConfig:           parklot,
	}
}

func (p *Parklot) EntryParking(vehicleId string, vehicleType string) error {
	if vehicleType == "car" && p.remainCarParkSpace == 0 {
		return errors.New("no space for car")
	}
	if vehicleType == "truck" && p.remainTruckParkSpace == 0 {
		return errors.New("no space for truck")
	}
	switch vehicleType {
	case "car":
		p.remainCarParkSpace--
	case "truck":
		p.remainTruckParkSpace--
	default:
		return errors.New("can`t parking "+vehicleType)
	}
	p.entryTimeStamps[vehicleId] = time.Now()
	return nil
}

func (p *Parklot) LeaveParking(vehicleId string, vehicleType string) int {
	var basePrice, maxPrice int
	switch vehicleType {
	case "car":
		basePrice, maxPrice = p.parkConfig.pricePerHourCar, p.parkConfig.maxDailyPriceForCar
		p.remainCarParkSpace--
	case "truck":
		basePrice, maxPrice = p.parkConfig.pricePerHourTruck, p.parkConfig.maxDailyPriceForTruck
		p.remainTruckParkSpace--
	}
	price := calPrice(basePrice, maxPrice, p.entryTimeStamps[vehicleId])
	p.feeCollected += price
	delete(p.entryTimeStamps, vehicleId)
	return price
}

func calPrice(basePrice, maxPrice int, t time.Time) int {
	dur := time.Since(t).Hours()
	price := int(dur) * basePrice
	if price > maxPrice {
		price = maxPrice
	}
	return price
}
  • 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

秒杀系统

https://blog.csdn.net/qq_35190492/article/details/107833096

高并发:秒杀的特点就是这样时间极短瞬间用户量大

超卖:

恶意请求:

链接暴露:

数据库:

具体实现:

前端:资源静态化,将能提前放到CDN的东西都放上去;为了避免链接暴露,直接访问url提前秒杀,可以采用链接加盐的方式,把url动态化。

限流:前端限流–秒杀前按钮都是置灰的、不会每一次点击都会有效,一般点一下或者点两下几秒之后才可以继续点击;后端限流–卖1000件商品,有10W个请求 ,可以只放1W个请求进来,然后进行操作。(为何不直接就放1000个请求进来呢?是为了下一步风控,算出最有可能是真实用户的1000人进行秒杀)使用Nginx负载均衡,多搞几台服务器。

风控:拦截恶意请求,让真正用户抢到商品而不是羊毛党。

后端:

服务单一职责,秒杀系统崩了不影响到其他的服务,保证高可用。

Redis集群、主从同步、读写分离,开启持久化保证高可用。

数据预热,提前将数据加载到Redis中。

万一顶不住:限流&降级&熔断&隔离。

消息队列,采用异步的方式,将消息放在队列中,然后异步修改库存。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-54SYrJpy-1689738567244)(1.Go+项目.assets/aHR0cHM6Ly90dmExLnNpbmFpbWcuY24vbGFyZ2UvMDA3UzhaSWxseTFnaGdkbDN1MWF2ajMwdzcwdHJuMTkuanBn)]

微信抢红包系统

如何设计一个RPC框架

RPC 是一个计算机通信协议。目的是调用远程服务像调用本地服务一样简单。

需要考虑的因素:

1、通过网络,要发送哪些数据,比如至少得发送类名或者接口名,要调用的方法名、调用方法时传入的参数等。

2、方法参数如果是对象,那么如果要考虑网络发送对象的问题,就需要考虑序列化和反序列化的问题了,使用何种序列化机制,也是要考虑的问题。

3、确定好了要发送的数据后,那通过什么方式发出去呢,是直接通过Socket发送,或者是http发送呢。

4、如果利用http来发,那就是用http1.1,或者是http2呢,请求头放什么数据,请求体放什么数据,都是问题。

5、如何直接通过socket来发,那就需要自己设计一个数据格式了,类似于htp协议,不然服务器接收到字节流之后无法解析字节流。

6、确定好数据格式和网络传输方式之后,就需要考虑是否支持异步功能了,是否支持回调功能

7、另外想负载均衡、服务容错、服务路由、服务重试等功能也要逐一考虑。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Yjb30RsK-1689738567245)(1.Go+项目.assets/2e0709ae6cdc6547be6abf4ad89729e7.png)]

如何设计一个注册中心

如何设计一个消息队列

如何设计一个持久化框架

如何设计一个高并发的计数器

方法一:使用 mutex 锁

方法二:使用 sync/atomic 包的原子操作

当我们想要对某个变量并发安全的修改,除了使用官方提供的 mutex,还可以使用 sync/atomic 包的原子操作,它能够保证对变量的读取或修改期间不被其他的协程所影响。

atomic 包的原子操作是通过 CPU 指令,也就是在硬件层次去实现的,性能较好,不需要像 mutex 那样记录很多状态。 当然,mutex 不止是对变量的并发控制,更多的是对代码块的并发控制,2 者侧重点不一样。

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

type Counter struct {
	value int64
	mux   sync.Mutex
}

func (c *Counter) Increment() {
	atomic.AddInt64(&c.value, 1)
}

func (c *Counter) Value() int64 {
	return atomic.LoadInt64(&c.value)
}

func main() {
	var wg sync.WaitGroup
	var counter Counter

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()

			counter.Increment()
		}()
	}

	wg.Wait()

	fmt.Println(counter.Value()) // 输出 1000
}
  • 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

自我介绍

面试官您好,我叫曹靖康,目前就读于武汉大学网络安全专业,今年研二,明年毕业,想应聘的岗位是go语言后端开发。我目前正在做的一个项目是微服务框架的开源构架,内容包括web相关的功能例如参数校验、日志处理、路由、认证等,并且还包括像服务发现 链路追踪 RPC和微服务相关的功能。今年也和团队的成员,在字节跳动举办的青训营比赛中实现了抖音APP后端常用功能,并且获得二等奖。项目实现了视频流、用户管理、评论点赞、以及好友管理等功能模块。借助实验室提供的平台,之前也有在北京军方实习完成项目的经历,参与过实验室项目信息推广系统的搭建。通过这些比赛、项目和实习经历,我的编程能力和个人抗压能力得到了不断的提高,对于go语言的底层知识也有了更加深刻的了解,解决问题的方法也不断的丰富。最后,想要通过实习提高个人的代码能力以及对业务的理解能力,学习一些在校园里面接触不到的知识。

反问的问题

具体的什么业务的以及技术栈

对实习生有没有什么培养方案

项目组有没有实习生,现在在做什么事情

实习待遇问题地点、时间、工资

小米一面问题总结:

设计一个读者写者 模型

如何理解云原生

auto-inc 自增到一个值后还是最大值

关键词 join

数据库优化的方法

url整个过程

rpc相关知识

进程线程协程区别,线程拥有哪些资源

mysql锁

如何保证协程安全的

如何建立一张数据库的表

如何防止SQL注入

相同前缀的最长长度

一个数组的全排列

如何实现http复用

小米二面问题总结:

消息队列实现

分布式日志管理系统

滴滴一面:

init执行顺序

结构体传值还是传指针

内存泄漏

GMP模型的P为什么不可以是多个

redis为什么采用单线程

channel存在数据丢失的问题吗

select原理

主从延迟

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Cpp五条/article/detail/411950
推荐阅读
相关标签
  

闽ICP备14008679号