写点什么

冲一下深信服,好像有点简单?

作者:王中阳Go
  • 2024-11-01
    北京
  • 本文字数:6767 字

    阅读完需:约 22 分钟

冲一下深信服,好像有点简单?

今天分享的是训练营的朋友在深信服的 go 后端社招面经,内容包含 go 基础知识、redis、docker、mysql、shell 命令、kafka 以及计网。覆盖面还是挺广的,并且都是一些很经典的题目,一定要看下去!


下面是我做的整理:


1. slice 原理 底层数据结构

这个题目不知道遇到过多少次了,大家一定要掌握好,考的频率太高了!

数据结构

slice 的底层实现是一个结构体,它包含三个主要字段:


  • array: 一个指向底层数组的指针(unsafe.Pointer),用来存储切片的实际数据。

  • len: 一个整数,表示当前切片中有效元素的数量。

  • cap: 一个整数,表示切片的容量,即底层数组中可以容纳的元素的最大数量。


在 Go 语言的 runtime 包中,slice 的结构体定义如下:


type slice struct {    array unsafe.Pointer    len   int    cap   int}
复制代码
特性
  • 引用类型:切片是引用类型,这意味着它们的值是对其底层数组的引用。因此,当一个切片作为函数参数传递时,实际上是传递了指向底层数组的引用。

  • 索引与切片:可以通过索引来访问切片中的元素,索引的范围是从 0 到 len - 1。切片操作符 : 可以用来创建切片的一个视图,如 s := a[start:end]。

  • 扩容机制:当向切片添加元素(如使用 append 函数)并且切片已满时,Go 语言会自动为切片分配更大的底层数组,并将原有元素复制过去。通常,新的容量会是旧容量的两倍,但这取决于具体的实现细节。

2. map 原理 底层数据结构

和上面一题一样,高频!!

数据结构

主要就是 hmap 和 bmap


type hmap struct {    // 元素个数,调用 len(map) 时,直接返回此值  count     int  flags     uint8  // buckets 的对数 log_2  B         uint8  // overflow 的 bucket 近似数  noverflow uint16  // 计算 key 的哈希的时候会传入哈希函数  hash0     uint32    // 指向 buckets 数组,大小为 2^B    // 如果元素个数为0,就为 nil  buckets    unsafe.Pointer  // 等量扩容的时候,buckets 长度和 oldbuckets 相等  // 双倍扩容的时候,buckets 长度会是 oldbuckets 的两倍  oldbuckets unsafe.Pointer  // 指示扩容进度,小于此地址的 buckets 迁移完成  nevacuate  uintptr  extra *mapextra // optional fields}
type bmap struct { tophash [bucketCnt]uint8}
// 但这只是表面的结构,编译期间会给它加料,动态地创建一个新的结构:
type bmap struct { topbits [8]uint8 keys [8]keytype values [8]valuetype pad uintptr overflow uintptr}
复制代码

a.扩容机制

当满足某些条件时会自动触发:


  1. 装载因子:这是 map 中元素的数量与桶的数量之比。当装载因子超过某个阈值(通常是 6.5 或者更高)时,map 会触发扩容。这是因为随着元素数量的增加,哈希冲突的概率也会增加,这会导致查找和插入操作的效率下降。

  2. 哈希冲突过多:如果在某个桶中哈希冲突太多,即多个键被哈希到了同一个桶内,那么为了减少冲突并提高性能,map 也可能会触发扩容。

  3. 渐进式扩容:Go 的 map 扩容并不是一次性将所有元素搬迁到新的哈希表中,而是采用了一种称为“渐进式”的方式。这意味着在扩容过程中,原有的 key 不会被一次性全部搬迁到新表,而是在后续的读写操作中逐渐地、分批次地迁移。每次最多只会搬迁几个 bucket,这样可以分散扩容带来的性能开销。

  4. 等量扩容:在 Go 的 map 实现中,扩容通常会创建一个新的哈希表,其容量是原来的两倍或更多。然后旧表中的元素会被重新哈希到新表中。这种扩容策略有助于减少哈希冲突,提升 map 的访问速度。

b.并发会导致什么问题?为什么?

可能会导致数据竞争和其他不可预测的行为。


因为它没有内置的锁机制来保护多个 goroutine 同时对其进行读写操作。


当多个 goroutine 同时对同一个 map 进行读写操作时,就会出现数据竞争和不一致的结果。


当两个或者多个 goroutine 同时尝试更新同一个键值对时,最终的结果可能取决于哪个 goroutine 先完成了更新操作。这种不确定性可能会导致程序出现错误或崩溃。


Go 语言团队没有将 map 设计成并发安全的,是因为这样会增加程序的开销并降低性能。


如果 map 内置了锁机制,那么每次访问 map 时都需要进行加锁和解锁操作,这会增加程序的运行时间并降低性能。


而且并不是所有的程序都需要在并发场景下使用 map,因此将锁机制内置到 map 中会对那些不需要并发安全的程序造成不必要的开销。

c. Map 的 key 是有顺序的吗?为什么?

key 是无序的


当 map 发生扩容时,其中的键(key)可能会被重新分配到新的桶(bucket)中。在扩容之前位于同一个桶中的键,在扩容后可能因为桶的数量增加(通常是翻倍)而分散到不同的桶里,这意味着一些键会被移动到索引增加了 2^B 的新桶中(这里的 B 是扩容前桶的数量的幂次)。由于遍历 map 时是按照桶的顺序进行,并且在每个桶内也是按序遍历键,因此一旦发生键的搬迁,遍历的结果将不会保持原有的顺序。


即使我们有一个硬编码的 map 并且不对其进行任何插入或删除操作,遍历这个 map 应该会返回一个固定的 key/value 序列。但是 Go 语言的设计者为了避免给新手程序员造成误导——即认为这种固定顺序是可依赖的特性——采取了措施来防止这种情况。


而且 Go 做得更绝,当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。

3. Go 垃圾回收,三色标记是哪三色,分别代表什么?

  • 白色:白色表示的对象是暂时还没有被标记的对象。在垃圾回收的初始阶段,所有的对象都是白色的。如果一个对象在整个垃圾回收过程中都保持白色状态,那么这个对象被认为是不可达的,最终将被垃圾回收器回收。

  • 灰色:灰色表示的对象是指那些已经被发现可达,但还没有完全处理的对象。在垃圾回收的过程中,从根对象开始,将可达的对象标记为灰色。然后垃圾回收器会处理这些灰色对象,查找它们引用的其他对象,并将这些对象也标记为灰色。灰色对象就像是垃圾回收器的工作列表,它会不断将灰色对象转换为黑色,并将它们引用的对象标记为灰色。

  • 黑色:黑色表示的对象是指那些已经被完全处理的对象。它们已经被标记为可达,并且所有它们指向的对象也都已经被标记。黑色对象不会被垃圾回收器回收,它们将继续存在于内存中直到它们变得不可达。

4. 堆和栈区别?内存何时分配在栈上?何时分配在堆上?

逃逸分析


堆(heap)和栈(stack)是两种不同的内存区域,它们各自有不同的用途和管理方式。

栈(Stack)

栈内存主要用于存储函数调用期间的局部变量、函数参数以及其他临时数据。栈的特点是先进后出(LIFO),这意味着最后一个进入栈的数据将是第一个被取出的。栈空间由操作系统自动管理,当函数执行结束时,分配给该函数的栈空间会被自动释放。


分配在栈上的情况:


  • 局部变量:在函数内部声明的局部变量通常是分配在栈上的。

  • 函数参数:传递给函数的参数也会分配在栈上。

  • 编译时常量:某些常量可能会被优化成栈上的数据。


栈内存的优势在于其分配速度快,因为它是连续的内存块,分配和释放都非常迅速。但是,栈的空间大小有限,而且一旦超出范围就会导致栈溢出。

堆(Heap)

堆内存用于存储程序运行时动态分配的对象。与栈不同,堆内存的分配和释放是由 Go 运行时环境(runtime)自动管理的。堆内存可以跨函数调用存在,直到垃圾回收器确定不再需要它们时才会被释放。


分配在堆上的情况:


  • 动态创建的对象:使用 newmake 关键字创建的对象通常位于堆上。

  • 大对象:当对象过大以至于不适合放在栈上时,会被分配到堆上。

  • 长生命周期的对象:如果一个对象需要在多次函数调用之间保持状态,则通常会被分配在堆上。

  • 返回的对象:函数返回的对象通常位于堆上,尤其是当这些对象需要在函数外部继续存在时。

  • 接口类型的变量:当一个变量实现了某个接口,并且该接口被赋值给另一个变量时,实际的对象通常是在堆上分配的。

5. 如何保证缓存一致性?

缓存一致性(Cache Coherence)是指在多处理器或多节点系统中,各个处理器或节点的缓存中的数据必须保持一致,以确保所有处理器看到的是相同的数据视图。这对于保证系统的正确性和可靠性非常重要。以下是几种常用的保证缓存一致性的方法和技术:

1. 缓存一致性协议

缓存一致性协议是一组规则,用于管理多个缓存之间的数据共享和一致性。常见的缓存一致性协议包括:


  • MESI 协议(Modified, Exclusive, Shared, Invalid)

  • Modified (M) :缓存行中的数据已被修改,且只有当前缓存拥有该数据的副本。

  • Exclusive (E) :缓存行中的数据是干净的,且只有当前缓存拥有该数据的副本。

  • Shared (S) :缓存行中的数据是干净的,且可能有其他缓存也拥有该数据的副本。

  • Invalid (I) :缓存行中的数据无效。

  • MOESI 协议(Modified, Owner, Exclusive, Shared, Invalid)

  • 在 MESI 的基础上增加了一个状态 Owner (O) ,表示当前缓存拥有该数据的副本,并且可以响应其他缓存的读请求。

  • MESIF 协议(Modified, Exclusive, Shared, Invalid, Forward)

  • 在 MESI 的基础上增加了一个状态 Forward (F) ,表示当前缓存可以转发数据给其他请求该数据的缓存。

2. 内存屏障

内存屏障是一种同步机制,用于确保内存操作的顺序性。在多处理器系统中,内存屏障可以防止编译器和处理器对内存操作进行重排序,从而保证缓存的一致性。

3. 一致性总线

在多处理器系统中,一致性总线负责协调各个处理器之间的缓存访问请求。当一个处理器需要访问某个内存地址时,一致性总线会检查其他处理器的缓存状态,并确保数据的一致性。

4. 一致性哈希

在分布式系统中,一致性哈希可以用于分散数据存储,确保数据在多个节点之间均匀分布。通过一致性哈希,可以减少节点加入或离开时的数据迁移量,从而提高缓存的一致性和性能。

5. 版本控制

在分布式系统中,可以通过版本号或时间戳来管理数据的一致性。每次数据更新时,都会增加版本号或时间戳。客户端在读取数据时,会检查版本号或时间戳,以确保读取的是最新的数据。

6. 乐观锁和悲观锁
  • 乐观锁(Optimistic Locking):假设数据冲突发生的概率较低,因此在读取数据时不加锁,而在提交更新时检查是否有冲突发生。

  • 悲观锁(Pessimistic Locking):假设数据冲突发生的概率较高,因此在读取数据时就加锁,以防止其他进程修改数据。

7. 分布式一致性协议

在分布式系统中,可以使用一致性协议来确保数据的一致性。常见的分布式一致性协议包括:


  • Paxos:一种分布式一致性算法,用于解决分布式系统中的共识问题。

  • Raft:一种更易于理解和实现的一致性算法,适用于分布式系统的领导者选举和日志复制。

6. redis 有哪些数据结构?常见用途

1. 字符串(String)
  • 存储简单的键值对:用于存储简单的数据,如配置信息、计数器等。

  • 计数器:通过自增和自减操作来实现计数功能。

  • 位操作:用于存储和操作位图数据,如用户签到记录、权限管理等。

2. 列表(List)
  • 消息队列:实现消息的入队和出队操作,常用于任务队列和消息传递。

  • 最近历史记录:存储用户的最近操作记录,如最近搜索记录。

  • 任务调度:用于任务的分发和处理,如异步任务队列。

3. 集合(Set)
  • 去重:确保集合中的元素唯一,常用于用户访问记录去重。

  • 集合运算:进行集合的并集、交集和差集操作,如好友推荐系统。

  • 随机元素选择:从集合中随机选择一个或多个元素,适用于抽奖等活动。

4. 有序集合(Sorted Set)
  • 排行榜:存储带有分数的数据,用于生成排行榜,如游戏积分排行榜。

  • 时间序列数据:按时间顺序存储数据,如日志记录、事件时间线。

  • 范围查询:根据分数范围查询数据,适用于筛选和排序操作。

5. 哈希表(Hash)
  • 存储对象属性:存储对象的多个属性,如用户信息、商品详情。

  • 快速查找和更新:快速访问和更新对象的属性,减少内存占用。

  • 复合键:用于存储具有多个字段的数据结构,提高查询效率。

6. 位图(Bitmap)
  • 用户签到记录:记录用户的签到情况,每个位表示一天的签到状态。

  • 统计在线用户数:记录在线用户的活跃状态,每个位表示一个用户。

  • 大规模布尔标志:存储大量布尔值,如权限管理、状态标记。

7. 地理空间索引(Geospatial Indexes)
  • 搜索附近的地点:根据地理位置搜索附近的地点,如餐馆、商店。

  • 计算距离:计算两个地理位置之间的距离。

  • 地理围栏:获取指定范围内的所有地点,用于位置相关的应用。

7. 虚拟机和 docker 区别

虚拟机通过虚拟化层在宿主机上创建完整的操作系统环境,提供强大的隔离性和高安全性,适合需要强隔离和高安全性的应用场景,但启动时间较长,资源消耗较大。


而 Docker 容器在宿主机操作系统上运行,共享同一个内核,通过命名空间和控制组实现资源隔离,启动速度快,资源占用少,适合快速部署、开发环境一致性保障和微服务架构。


虚拟机适用于复杂的开发和测试环境以及多租户场景,而 Docker 更适合持续集成/持续交付(CI/CD)和轻量级应用的快速迭代。

8. Docker 底层原理 通过什么实现

Docker 的底层原理主要依赖于以下几个关键技术:


  1. 命名空间:实现进程、网络、文件系统等资源的隔离,使每个容器看起来像是在独立的系统中运行。

  2. 控制组:限制和监控容器使用的 CPU、内存、磁盘 I/O 等资源,确保资源不会被过度消耗。

  3. 分层文件系统:允许多个文件系统层叠加,实现镜像的高效存储和快速启动。

  4. 网络管理:通过不同的网络模式(如 Bridge、Host、None、Overlay)管理容器间的网络通信。

9. 写代码实现两个协程交替打印 100 以内数字

一个简单的代码题


package main
import ( "fmt" "sync")
func main() { // 创建两个通道用于同步 ch1 := make(chan struct{}, 1) ch2 := make(chan struct{}, 1)
// 创建一个等待组,用于等待两个协程完成 var wg sync.WaitGroup wg.Add(2)
// 第一个协程 go func() { defer wg.Done() for i := 1; i <= 100; i += 2 { <-ch1 fmt.Printf("协程1: %d\n", i) ch2 <- struct{}{} } }()
// 第二个协程 go func() { defer wg.Done() for i := 2; i <= 100; i += 2 { <-ch2 fmt.Printf("协程2: %d\n", i) ch1 <- struct{}{} } }()
// 启动第一个协程 ch1 <- struct{}{}
// 等待两个协程完成 wg.Wait()}
复制代码

10. Mysql 如何优化慢查询

  1. 启用慢查询日志

  2. 启用慢查询日志,记录执行时间超过指定阈值的查询。

  3. 使用 EXPLAIN 分析查询

  4. 使用 EXPLAIN 命令分析查询的执行计划,找出潜在的性能瓶颈。

  5. 创建合适的索引

  6. 为经常用于查询条件的列创建索引。

  7. 考虑创建复合索引,优化多条件查询。

  8. 避免过度索引,删除不必要的索引。

  9. 优化查询语句

  10. 减少查询结果集,只查询需要的字段。

  11. 避免使用 SELECT *,明确指定需要的字段。

  12. 使用合适的数据类型,减少存储空间和提高查询性能。

  13. 尽量使用 JOIN 替代子查询,提高查询效率。

11. Shell 命令考察

a.如何判断一台服务器还是活的

  1. 使用 ping 命令

  2. 测试网络连接是否通畅。

  3. 命令:ping -c 4 <服务器IP地址>

  4. 使用 nc(Netcat)命令

  5. 测试指定端口是否开放。

  6. 命令:nc -zv <服务器IP地址> <端口号>

  7. 使用 curl 命令

  8. 测试 HTTP 服务器是否响应。

  9. 命令:curl -I http://<服务器IP地址>:<端口号>

b. 给一个文本文件 取出其中特定的列数据 然后排序

使用 awk 提取特定列并排序

假设你有一个名为 data.txt 的文件,内容如下:


1,Alice,302,Bob,253,Charlie,354,David,28
复制代码


提取第二列数据并排序:


awk -F',' '{print $2}' data.txt | sort
复制代码

12. kafka 如何保证消息不丢失

Kafka 可以通过多种机制来保证消息不丢失,主要包括以下几点:


  1. 副本机制

  2. Kafka 为每个分区配置多个副本,确保数据的冗余。即使某个副本宕机,其他副本仍然可以提供数据,从而保证消息不丢失。

  3. ISR(In-Sync Replicas)机制

  4. ISR 列表包含所有与 leader 副本保持同步的副本。只有 ISR 列表中的副本才能接收写请求,确保数据的一致性和完整性。

  5. 生产者确认(ACKs)机制

  6. 生产者发送消息时,可以通过设置 acks 参数来决定确认的级别:

  7. acks=0:生产者不等待任何确认,可能导致消息丢失。

  8. acks=1:生产者在消息被 leader 副本确认接收后,视为发送成功。

  9. acks=all:生产者在所有 ISR 副本都确认接收到消息后,才视为发送成功,这是最可靠的设置,但会降低性能。

  10. 消费者手动提交偏移量

  11. 消费者在处理完消息后,手动提交偏移量(使用 commitSync() 方法),确保消息被正确处理后再提交,避免消息丢失。

13. udp 报文头部有哪些字段?

UDP 报文头部包含以下四个字段,每个字段都是 16 位(2 字节):


  1. 源端口:发送方的端口号。

  2. 目的端口:接收方的端口号。

  3. 长度:UDP 报文的总长度(包括头部和数据部分)。

  4. 校验和:用于检测传输过程中是否有错误,有错则丢弃。

欢迎关注 ❤

我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。


没准能让你能刷到自己意向公司的最新面试题呢。


感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:infoq 面试群。

用户头像

王中阳Go

关注

靠敲代码在北京买房的程序员 2022-10-09 加入

【微信】wangzhongyang1993【公众号】程序员升职加薪之旅【成就】InfoQ专家博主👍掘金签约作者👍B站&掘金&CSDN&思否等全平台账号:王中阳Go

评论

发布
暂无评论
冲一下深信服,好像有点简单?_Go_王中阳Go_InfoQ写作社区